库存 MCP
把方案包里那几十张飞书源表(当前 31 张)读出来、筛选去重、归一、聚合,做成十三个工具给 agent 调;每周导进飞书台账。
零依赖:MCP 走 stdio 上的 JSON-RPC,没有 node_modules,装到别的机器上只要有 node。
装
WorkBuddy 里点「MCP 列表 → 编辑配置」,或者直接改 ~/.workbuddy/mcp.json:
{
"mcpServers": {
"inventory": {
"command": "node",
"args": ["/path/to/inventory-mcp/server.mjs"]
}
}
}
Claude Desktop 或别的 MCP 客户端同理,配置形态一样。
十三个工具
| 工具 | 参数(全可选) | 回什么 |
|---|---|---|
查库存 |
资产类型 / 型号 或 槽位 / 型号们 / 品牌 / 库房 / 要几根 / 明细几行 / 要可用量 / 一定要文件 | 匹配的物料键、各自根数、按库房小计(带梯队)、合计、收尾那一行(给了「要几根」才算),不够时自动放宽一项并把候选一起带回来;回显「这次查的」(解析后的槽位,给下一轮照抄) |
看分布 |
资产类型 / 前几个 | 「型号 × 库房」矩阵 + 每个库房合计 |
找替代 |
型号 / 资产类型 / 要几根 / 库房 / 每档几个 | 按「机器能证明多少」分四档:精确 / 规则 / 存疑 / 相近,另给「凑法」;人拍过的候选带一句「人拍过」 |
查SN |
型号 / 品牌 / 库房 / 资产类型 / 物料键 / 最多列几条 / 一定要文件 | 一根根的序列号。超过 50 个就写 CSV、只回路径,见下 |
看变动 |
和哪天比 / 资产类型 / 库房 / 前几个 | 和周基线比进出了什么。先报按 SN 算的真进出,键级变化降级,见下 |
看源表 |
资产类型 / 库房 / 字段 / 要不要每张的明细 | 这些数从哪张表哪一列来的。只读方案包、不打网络,见下 |
看有哪些型号 |
资产类型(必填) / 带槽位 / 带根数 | 这一类在库里的全部标准写法 + 有哪些品牌各多少货(封闭清单)。客户写法不标准时从里面挑,见下 |
看筛选 |
资产类型 / 只看变了的 | 每条筛选规则认识哪些取值、各多少行,以及和上次比变了什么。这是原材料不是结论,见下 |
写占用 |
工单号 / 项目 / 配件需求 [{型号,数量}] / 真写 |
需求满足后把配件登记进占用台账(闭环最后一步)。传 cmdb「查工单」出的配件行(只 内存/硬盘/光模块/网卡)。每条按可用量分:满足→占用/审批中(关联真键)、缺口→采购在途(建/复用同型号虚拟待采购键)。默认干跑回计划+汇报,真写传 真写:true(低风险可删、不弹确认框);整机/耗材/线缆在 cmdb 侧已筛掉 |
发通知 |
工单号 / 项目 / 配件需求 | 匹配完把通知拼好、按收件人分段返回(不真发):满足→给「资管(台账管理者)」一段、缺货/待定→给「采购(采购对接人)」一段。你复制粘到对应飞书对话框自己发——发件人是你、绕开机器人可用范围/租户策略,"复制→发送"就是审核那一步。缺货=匹配上库存但不够;待定=型号没对上库存(请核对写法)单独列。只读台账、不发消息、不要发消息权限。为什么不 bot 直发:实测用户身份发被租户策略挡(230027)、bot 私信到别人要那人进 app 可用范围(230013)、bot 发群要 bot 先在群里(230002)——返回文本你自发全绕开 |
日更 |
确认票 / 用户选择 | 每天刷新台账+注册表+在途,出「该变没变」红检查自检回执(不是光盖时间戳)。两步确认(真写是批量写台账/注册表):① 不带参调→干跑出回执(台账 新增/更新/置0、注册表 新发几键、在途还剩几条)+ 确认票,不写;② 你必须先调 AskUserQuestion 把回执给用户看、问真写吗,带票+用户选择重调才写,真写后跑完整红检查。走飞书不要公司网;工单同步要公司网、跳过并在回执标。逻辑在 lib/日更运行.mjs,命令行版是根目录 日更.mjs --真写 |
周更 |
确认票 / 用户选择 / 认下筛选 | 每周四跑(日更的超集):重读源表 → 和上周四基线比(周环比:出/入库、哪些键真归零→占用悬空、在途到货没)→ 品牌待补 → 存本周基线 → 写台账。两步确认同日更(干跑出回执+票 → AskUserQuestion → 真写)。硬规矩:有源表读不到就不存基线、不写台账。逻辑在 lib/周更运行.mjs,命令行薄壳 周更.mjs。导台账是它的子集(都调 写台账),不单独做工具 |
记下决定 |
全必填:资产类型 / 要的 / 顶上的 / 结论 / 依据 / 谁拍的 | 把人当场拍板的替代决定记下来,下次不用再问。唯一往磁盘写「判断」的工具,见下 |
参数一律扁平,数组只装字符串,没有嵌套对象 —— 便宜的模型对嵌套结构容错差。
跨类一次问完写成 型号们: ["光模块:SR4","硬盘:960G"],仍是扁平字符串数组。
参数改过名就在协议层顶回去,不静默忽略。 找替代 的「数量」2026-08-14 并成了
「要几根」(和 查库存 同一个名字 —— 同一件事两个名字,模型迟早发错那个)。
静默忽略的表现是:模型拿到一份看着正常的返回,只是「够不够」那一块整个没了,
一个不报错的缺块。所以老名字直接回 -32602 让它换个名字重发。
型号怎么匹配:走槽位,没有字符串这条路
「型号」不是子串匹配。 实测过一次真事故:问 OSFP112-800G-2*DR4-SM1310 子串匹配命中 0,
而库里有 18,000 根 OSFP112-RHS-800G-2*DR4-SM1310 —— 中间多一段 RHS 整串就对不上,
模型据此报「库里没这个货」。现在两边都解析成 form/rate/std/media/wave 再逐项比,
连裸关键词都吃得下(SR4 解析成 {std:SR4, media:MM, wave:850},精确命中 35 个键)。
链路是死的:
模型(对着 instructions 里两张常驻表:槽位词表 351 token + 品牌名单 93 token)
定资产类型(必填,机器不猜)→ 把客户的乱写法翻成槽位 → 挑品牌标准名
↓
查库存 → 校验槽位值在词表里(不在当场报错并列出合法值)
→ 逐槽位相等才算命中 → 品牌精确匹配(不是包含)
↓
命中 0,或者命中了但不够「要几根」
→ 自动放宽收益最大的那一项,把明细直接带回来(不让模型再问一轮 ≈ 8,000 token)
→ 「没查到」+ 你的槽位是什么 + 差得最少的 5 个,每条带**逐槽位对照**
(对上的和没对上的都列 —— 只列差异的话,人分不清「其余几项真的相同」
还是「其余几项压根没比」)
放宽不是只在零命中时做。 命中 300 根、客户要 2,800 根时,放宽之后的 8,932 根才是答案,
而只在 命中 === 0 时算的话,这种情况一个字都不给。判据是「本档够不够 要几根」,
不是「有没有命中」——所以不填 要几根 就不会触发,工具只答「有多少」。
放宽是读操作:摆出候选不等于断言它能用。返回里写清放宽了哪一项、其余槽位逐项相同,
「能不能插」留给人和模型判(QSFP 和 QSFP28 是同一个笼子的两种写法,
SR4 多模 和 LR4 单模 就是不通)。放宽封装之后冒出好几种封装时挂一条 ❓ 这里该问人。
分工是死的:模型做翻译和选择,机器做判断。 翻译的输出受词表约束、当场可验; 「两组槽位是不是同一款」一行都不交给模型 —— 交了的话同一对型号今天判同款明天判不同。 判据是**「答案在不在一个可枚举的集合里」**:在(资产类型 4 个、某类型号 80 个、 品牌 32 个、槽位值 24 个)→ 给模型挑,挑错了当场可验;不在(A 能不能替代 B)→ 机器算。
不猜资产类型。 原来是拿四类解析器都试、谁填的槽位多算谁 —— 189 个真型号里猜不出 61 个,
另有 5 个被多类同时认(128GB 2Rx4 PC5-5600B 光模块和内存都认),靠一票之差分胜负。
现在库里没这个写法又没给资产类型就报错让人补。
但只有一处属性真的定不了类,其余都能(2026-08-15 把四类真实写法全解析了一遍):
101 个 槽位=值 里 96 个只被一类用过,共用的只有 rate 的 5 个值 ——
10G / 25G / 100G / 200G / 400G,光模块和网卡都有。内存的 cap(16/64/96/128G)和硬盘的
cap(480G 起)一个都不撞。所以 instructions 里只写了这一条提示(约 122 token,
一次会话发一遍),没有建「容量→资产类型」的索引 —— 索引强的地方(QSFP28→光模块、
3.84TB→硬盘)模型本来就对,索引弱的地方(只给一个 400G)它也一样卡住,
建表是在模型已经对的地方加确定性、在它不行的地方帮不上,还多一套要防过期的东西。
槽位这条路的两个洞(2026-08-23 量出来的)
问一个具体型号,回来的可能是整个资产类型的库存,而回包里写着「按槽位匹配,库里逐项相等的才算命中」。
两个洞的根子是同一个:比一个() 只遍历核心槽位,而且「需求没给的槽位 → continue」(不约束)。
一路 continue 到底,就是「每一行都逐项相等」。
洞一:需求里一个核心槽位都没有。 两条路都能走到:
| 怎么发生的 | 实测 |
|---|---|
型号解析出空槽位集。库里 189 个真型号里有 23 个是这样(硬盘 12 / 网卡 9 / 内存 2),全是厂商料号:MZQL23T8HCLS-00B7C(三星)、SSDPF2KX038T1(英特尔)、MCX653105A-HDAT(迈络思)、900-9D3D4-00NN-H(英伟达)—— 料号里没有人能读的规格 |
问 MZQL23T8HCLS-00B7C → 回硬盘 62/62 种 |
显式给的槽位全落在 core 之外。光模块的 lanes、硬盘的 media 在词表里、解析槽位串 会放行,但 core 里没有它们,压根不参与比较 |
lanes=2*DR4 → 光模块 80/80;media=SSD → 硬盘 62/62 |
方向是最坏的那个 —— 多报:少报人会追问,多报人直接许出去了。
修法:按槽位配 先算「有几个槽位真会被拿去比」(= core ∩ 需求)。为 0 时不再返回匹配,改成两支——
- 这个写法的字面指纹在库里 → 只回它自己那几条,标
只按字面,同时说清「库里可能还有同款的别的写法,这次没找」。 - 字面也不在 → 回「没查成」+ 怎么办(照词表翻成核心槽位重查,或调
看有哪些型号拿清单挑)。
字面比的是 ledger.字面指纹(大写 + 去分隔符,和 占用写.归一型号、normalize 分组同一个口径),
不是严格相等 —— 严格相等量过一次,客户把料号写成小写就全找不回来了(-21 条)。
不做前缀/包含匹配:那条路是这个仓库特意删掉的,会漏掉「中间多一段」的同款货(实测漏 18,000 根)。
洞二:只解出一部分核心槽位,却报成精确匹配。 这个比洞一普遍得多 —— 189 个真型号里 86 个是部分覆盖。 没解出的那几项不参与比较,语义上说得通(没约束的不筛),但那句「逐项相等才算命中」一个字都没变, 于是「真精确」和「只约束了五分之一」在回包里长得一模一样。实测放大倍数:
| 解出几项核心槽位 | 平均认回几种货 |
|---|---|
| 光模块 5/5 | 1.3 |
| 光模块 4/5 | 2.4 |
| 光模块 3/5 | 4.0 |
| 光模块 1/5 | 38(80 种里的 38。那个型号是 AFBR-709SMZ 850nm LASER PROD,五项里只认出 wave=850) |
| 硬盘 4/4 | 1.0 |
| 硬盘 1/4 | 6.7 |
修法不是拒答(客户说 3.84T 时回 11 种是对的),是把覆盖率说出来:
按槽位配 返回 解析覆盖 {参与比较, 没参与, 全覆盖},怎么筛的 在没全覆盖时明说
「这不是精确匹配 —— 4 项核心只约束了 cap,bus、form、gen 没参与比较,
所以下面 11 条里混着 bus/form/gen 各不相同的货。报给人时别说成「就是这个型号」」。
顺带发现召回率那个 100% 有一部分是假的。 那 23 个料号原来命中整类,真值自然在里面 → 被算成召回。
修完 带项目尾巴 那行掉到 88%,逐条归因确认掉的 23 条全是这批料号、一条真回归都没有,基线才降的
(见 tests/召回.test.mjs 里那段基线注释)。其余几行靠字面指纹回到了原水位:
全小写/连字符换空格 回到 100%,去掉所有分隔符 回到 96%(剩的 8 条是修之前就没中的真缺口)。
三道闸都从源码里拿掉验过必红:洞一那道 → 查库存.test.mjs 红;字面改回严格相等 → 召回.test.mjs 红;
洞二那段 → 查库存.test.mjs 红。
三个「给模型看的清单」,边界是死的
看源表 这个数从哪张表、哪一列来的 —— 答来源
看有哪些型号 这一类有哪些标准写法和品牌 —— 答清单,给模型挑
看筛选 每条规则认识哪些取值、变了什么 —— 答原材料,给模型判
看筛选:交出去的是原材料,不是结论
45 条规则(表 × 规则字段)各认识哪些取值、各多少行,加上和上次基线的 diff。
全量约 1,500 token,只看变了的: true 约 170。
上一版这里是机器判的:「命中率掉超过 20 个百分点 = ⚠ 明显下滑」。那个阈值是拍的、 标着未验证,而同一个变化在三种情境下意思完全不同 —— 人主动改了筛选规则 / 源表的列被改了 / 真的进了一批新状态的货,机器分不出是哪种。实测还有三张表常年 7% / 15% / 20%(全量资产台账, 大部分行是「在线」的在用设备),任何绝对阈值都会把它们误报成异常。
现在的分法:
| 谁 | 干什么 |
|---|---|
| 机器 | 收集 45 条规则的取值分布;和上次做 diff(纯 diff,无判断);「读到有效行却一条都没命中」这个零误报的绝对判据留着 |
| 模型 | 这些变化是哪种情境;某个取值被排除掉了要不要紧(借测 288 行 这种) |
| 人 | 滚不滚基线 —— ./周更.mjs --认下筛选,工具调不到这个开关。能被自己清掉的警报等于没有警报 |
看有哪些型号:封闭清单,让模型挑
某一类在库里的全部标准写法。光模块 80 个 = 1,128 token、硬盘 62 个 = 512、内存 17 个 = 285。
客户的写法不标准、模型拿不准对应库里哪个时调它 —— 从清单里挑一个确切的再去查。
带槽位: true 每条附上解析结果(光模块涨到 4,025 token)。
一并给这一类有哪些品牌、各多少货(按根数倒序,只列有货的)—— instructions 里那份品牌名单
只有「标准名 ← 别名」,模型挑品牌时看不见分布,可能挑一个库里根本没有的,
然后拿到 0 却分不清是「没这个牌子」还是「自己翻错了」。
那两个枚举从方案包现抠,不在 server.mjs 里写死(现抠枚举(),lib/ledger.mjs)——
和「槽位解析从油猴现抠」同一条规矩。原来它们各被复制了 9 遍(五个工具的 inputSchema),
方案包里加一类资产或一个库房,这边毫无反应:模型筛不到它、那批货就查不出来,而且不报错。
抠不到方案包时不给 enum,而不是给一个空 enum——空 enum 等于「什么都不许填」,
是个静默的全禁;这时参数退化成自由字符串,第一次真调用会带着真正的原因失败。
tests/direct.test.mjs 判据 ㊳ 直接 grep server.mjs,写死一个就红。
在库 ≠ 可用量:在库来自方案包里那些源表(实时),占用中来自台账的占用记录汇总,
可用量 = 在库 − 占用中。对外承诺以可用量为准。读台账实测 4.4 秒,所以按需读:
| 调用 | 耗时(热态) | 答什么 |
|---|---|---|
查库存(默认) |
2.5 秒 | 在库,带一句「这是在库不是可用量」 |
查库存 要可用量=true |
5.1 秒 | 在库 / 占用中 / 可用量三个数 |
看分布 |
2.5 秒 | 不读占用 |
找替代(默认就读) |
4.6 秒 | 「够不够」必须扣掉被占的 |
查SN |
2.5 秒 | 不读占用(要的是清单,不是能不能拿) |
看变动 |
2.5 秒 | 不读占用 |
看源表 |
0 秒 | 只读 plans.json,一次网络都不打 |
三个工具的默认值故意不一样:查库存 问的是「有多少」,找替代 问的是「能不能顶上」——
后者隐含「能不能拿到」,说够了但实际被占了,人会白跑一趟采购。所以找替代不给关闭选项。
找替代 的每条候选同时给「在库」和「可用量」,够不够 和 小计 都按可用量算,
排序也按可用量(在库多但被占光的不许排前面)。两个数的差本身就是人要知道的。
相近档里只差一个槽位、其余逐项相同的单独标一句 只差这一项(QSFP28-100G-SR4
vs QSFP-100G-SR4 只差 form)。人拍过:不当同一款,但相关类要做推荐 ——
所以它们仍然只进相近档,绝不进精确/规则;标的是「差在哪一项、其余哪几项相同」这个
算出来的事实,不说「所以能替代」,那是光模块知识、不是槽位算得出来的。
相近档默认给「分层」,不给「前 N 条」:按差几项分组,每层报个数、根数、前 2 个代表
(带完整差异和 只差这一项 整句)。实测一次查询相近档全量 192 条 ——
差 1 项 18 条/6,640 根、差 2 项 16 条/21,664 根 … 差 5 项 57 条/24,434 根。
只给排序后的前 5 条时,被截掉的 187 条只剩一句「另有 187 个规格」,人看不出它们差在哪个量级上;
分层之后「差 1 项 18 条」和「差 5 项 57 条」是两个完全不同的信号。
同样「差几项」还要按代价再拆一层(2026-08-15):差一个 wave(1310 vs 1300,能放)
和差一个 std(DR4 vs FR4,多模换单模、根本不通)都是「差 1 项」,混在一层里人得整层翻完
才知道哪几个值得看。分层键是「差几项 + 这几项里最重的那个放宽代价」,每层还带一句
怎么读(能放 → 先看这一层;不能放 → 除非人有别的依据否则别推)。
取最重不取平均:差两项里只要有一项不能放,这个候选就是不能放,另一项多好都不改结论。
排序先按差几项、同差几项按代价从轻到重(判据 ㊳㊴㊵ + 消融 10)。
平铺的「相近候选」和分层必须同序(判据 ㊶㊷ + 消融 11)。加分层的时候差点埋一个坑:
平铺列表还按老规矩排(差几项 → 比需求低 → 根数降序),三个候选都「差 1 项」时一路落到
按根数降序,而根数最多的恰好可能是最不该推的 —— 实测差 std(不能放,33 根)排第一、
差 wave(能放,11 根)排最后,而分层里正好倒过来。同一批货两种视图顺序相反,
看分层的人第一眼看到最该看的,要平铺列表的人第一眼看到最不该看的。
现在「最重代价」进了排序键,排在根数前面:一个插不上的货有再多根也没用。
排序后的那份全量列表没删(截断行为、层内排序、每条差异数这些只有它验得了,砍过一次
8 条判据当场没了依据),要它就显式传 每档几个。
占用读不到时不退回在库冒充:候选里干脆不给「可用量」这个字段(给一个等于在库的
「可用量」比不给危险得多,它看着是个已经扣过的数),小计.按什么算的 和 够不够
两处都自报是按在库判的、并带 ⚠。tests/substitute.test.mjs 消融 4 拿掉这一层就必须变红。
台账读不到时不让整个查询失败,改报 ⚠ 可用量算不出来——「没人占」和「算不出来」是两回事。
每个返回值都挂同一个「口径」块(数据来源、读取时间和身份、在库总根数、品牌怎么补的、
坏件排除、SN 对比、筛选命中率)。不做成独立的「查数据新鲜度」工具——那样模型不会
主动去查,人就看不到。以 ⚠ 开头的字段工具描述里要求模型必须原样转达。
口径块里只留会改变这次回答的字段。各段耗时、读取秒、筛选去重链路、结构缓存、
归一改动、判据来源、台账原始行数这几项是给写代码的人调试用的,模型每次都要读完,
几轮下来是纯噪音——实测占口径块一半、占整个返回体约两成。默认不发,INVENTORY_VERBOSE=1
才发。不是删掉:出问题时那些数字是唯一的定位线索。tests/scope.test.mjs 守住
「该发的一条都没被瘦掉」——少发一个耗时数字没人受伤,少发一条「品牌是补的」
就是把「这个牌子是我们猜的」藏了起来,而藏起来不会报错。
返回体第一个字段是「怎么答」
便宜的模型会把结果写成一大段流水账。格式指令放在返回体的第一个字段,因为它是 从上往下读的,指令排在几千 token 数据后面基本不生效;只写在工具描述里也不行—— 描述在会话开头读一次,几轮之后就被挤远了,而返回体是它每次组织答案时都要重看的。
怎么答: 先出一张表:品牌 | 型号 | 库房 | 在库 | 占用中 | 可用量。
表下面用短句补这几条,一条一行:⚠ 开头的每一条原样带上、
同一型号在多个库房时按库房逐行列,不许加总成一个数、数据读取时间。
别写查询过程、别复述字段名、别加收尾总结段。
梯队不进表头。 它是拿来判「这批货要不要跨库房协调」的,不是给人看的列 —— 人要的是「哪个牌子、在哪个库房、有多少」。塞进去会让每张表多一列没人看的数字。
代价约 130 token/次,换掉的是一整段「我调用了查库存工具,查询到以下结果……」。
它只管形状——哪些字段必须转达仍归各自的 ⚠ 和工具描述。
参数回显:把这次实际用的条件写回去
多轮追问是模型最容易错的地方,而且错了不报错:人问完「闵行有多少 400G DR4」接着说 「那临港呢」,模型要靠回忆自己上一轮传了什么 —— 记漏一个槽位查出来多一大截、 多带一个查出来少一大截,两种都拿到一个看着正常的数,人也看不出那不是他问的东西。
查库存 现在把这次实际用的条件回显出来,排在数据前面(排在几千 token 数据后面模型读不到):
这次查的: { 资产类型:'光模块', 槽位:'rate=400G,std=DR4,media=SM,wave=1310', 库房:'闵行' }
换条件时: 照抄「这次查的」改一项,别凭印象重写 —— 少一个槽位会多查出一大截、
多一个会少一大截,两种都不报错。
回显的是解析后的槽位,不是原样回参数。 上面那个例子里模型传的是 型号:"400G DR4",
而 DR4 这个标准自动推出了单模和 1310 波长 —— 它实际问的是四个约束,自己不知道。
回显之后它看得见,想放宽就能精确删掉 wave=1310 那一段,而不是整条重写。
必须序列化成 槽位 参数本来收的字符串形式(k=v,k=v),不能回一个对象 ——
回对象看着更结构化,但模型贴不回去,往返就断了。
判据是往返,不是「回显里有没有这个字段」(tests/protocol.test.mjs ㉜㉝㉞):
拿回显原样重查,合计必须一模一样。只验形状的话,「回显一个对象」这种写法会绿而功能是坏的。
不上「查询 ID」那套:那要工具端存状态,回显不用。
收尾那一行:工具算,模型抄(lib/凑单.mjs)
一批型号查完,人要的是每款一行「够不够、从哪儿调」。这一行是人拿去下单的依据 —— 它说「山西 302 + 临港 283」,人就按这个数去两个库房调货。 让模型自己从明细里加,加错了不会有任何东西报错,货到了才发现少几百根。所以机器算:
一处就够 QSFPDD-400G-DR4:光迅·山西 满足
要凑好几处 QSFPDD-400G-DR4:光迅·山西 302 + 海光芯创·临港9号楼 283 = 585 满足
凑不够 QSFPDD-400G-DR4:全部 8 处合计 1073,缺 1727
没给「要几根」 QSFPDD-400G-DR4:光迅·山西 (附「这只是货最多的那一处,不代表够」)
三条规矩:
- 凑出来的每一处各自带数量。 写成
光迅+海光芯创·山西+临港9号楼 满足语法上没错、 读着也顺,但人不知道该去山西调多少、去临港调多少 —— 而这个错不会让任何东西红。 - 凑不够时不逐处铺开,只报合计和缺口。那 N 处的数在「按库房」里本来就有, 摆进这一行只会让人以为「这些加起来就是答案」。
- 没给「要几根」就不许出现「满足」(返回
够: null)—— 工具没算过够不够, 这时候写「满足」是模型在替人下结论。
「用几处是最少的」靠贪心,而贪心在这个问题上就是最优解:取最大的 k 个能让 k 项和最大, 所以第一次够的那个 k 就是最小 k。前提是「每处能全取」 —— 哪天要加「某库房最多调 200 根」 这种上限,这条就不成立了,得换算法。
换机器 / 出问题:先跑 ./自检.mjs
./自检.mjs 全查一遍(会真读几张源表,约 8 秒)
./自检.mjs --快 跳过真读那步,不打网络
这套东西有三样不在这个仓库里,光看代码看不出来:
| 缺什么 | 表现 | 怎么办 |
|---|---|---|
lark-cli |
起不来 | 它跟 WorkBuddy 走,不是单独装的 —— 装 WorkBuddy 并打开一次。换了位置设 INVENTORY_LARK_CLI |
plans.json |
起不来 | 按顺序找:INVENTORY_PLANS → 仓库根的 plans.json → (方案包目录)。换机器时拷进仓库根就能跑,里面没有任何密钥。它的家在油猴那个仓库、由「品牌对照表」界面维护,所以这边不留副本 |
| 飞书那边的读权限 | 「读不到表」 | 最容易卡、也最看不出来的一环 —— 它和路径错、网络断长得一模一样。找表的维护方开权限 |
登录态不用配:lark-cli 用的就是 WorkBuddy 那套(identitySource: auto_detect),
装的人登录自己的飞书账号就行,不用给任何密钥。
发通知 现在不真发(只拼文本返回,你自己粘发),所以不需要任何发消息权限。若以后要 bot 自动发,实测出来的门槛:用户身份发被租户策略挡(230027);bot 私信到别人要那人进 app cli_aae5ee90f8f85cc5 的可用范围(230013)、发群要 bot 先在群里(230002);bot 发消息要 --as bot + im:message scope(lark-cli auth login --recommend)。唯一零门槛路径是「bot 发它在的群 + <at user_id> @人」。
每条红的后面都跟一句「怎么办」,而且一项坏了不挡后面的 ——
换机器时人想一次看全,不想修一个跑一次。这两条都有判据守着(tests/自检.test.mjs)。
三件静态检查(都在 15 秒那档里)
语言级的错交给该语言的工具,不自己写正则。三件都是全局装的(shellcheck / eslint 用 brew 和 npm -g),
仓库本身零 JS 依赖 —— ESLint 用全局二进制 + 仓库里一个 eslint.config.mjs,不要 node_modules。
| 工具 | 抓什么 | 今天它第一次跑就挑出的东西 |
|---|---|---|
shellcheck |
shell | 我刚写的 ls | wc -l(SC2012)、[ -n "$(grep …)" ](SC2143) |
eslint |
JS 的 no-undef / no-unused-vars |
三处死代码;以及回放验证时精确指到 server.mjs:1408:11 'name' is not defined |
ast-grep |
按语法树改名(不是检查,是改代码时用) | — |
只开 no-undef 和 no-unused-vars,风格类一条不开。 这个仓库的取舍(中文标识符、长注释、
内联三元)是有意的,让 linter 管风格只会制造一堆要豁免的噪音,而噪音会让人连真错也一起忽略。
接这类工具时防三件事,缺一件它就会「失效时是绿的」:
- 没装不许静默跳过 —— 那样它永远「通过」。
- 看它扫了几个文件 —— 报 0 个文件和报 0 个问题长得一样,而前者是没检查。
shellcheck 还要额外看
SC1088:它遇到不认识的语法会停止解析、照样退非 0, 260 行只扫 28 行而看起来在干活(这就是run-tests.sh里函数名叫sec/run_one/teeth/chain的原因)。 - 只认退出码,不认输出文本 —— 第一版拿
grep ' error '匹配 eslint 输出, 而它印的是[Error/no-undef](大写、没空格),一条都匹配不上, 于是 eslint 报了错而这条检查印 ✓。一个专治「失效时是绿的」的检查,自己失效时是绿的。
批量改名一律用 ast-grep,不用 sed / 字符串替换。 实测对比(同一段代码里「窄读」有三种身份):
盲替换 注释、字符串、词义不同的地方全被换 —— 4 处里 3 处是错的
ast-grep 只换标识符那 2 处,注释和字符串一个字没动
sed 的 \b 对中文不起作用(s/\b中文\b/x/ 一个字都不会改,而你以为改了)。
还有一个 shellcheck 也不报的形态:$var 后面紧跟中文标点时,bash 会把那几个字节
当成变量名的一部分($es_code) 印出乱码)。变量后面接非 ASCII 一律写 ${var}。
第三个形态,是上面那次改名自己制造的。 e9d5aa8(08-17 23:28,就是"函数名改 ASCII"那次)
把 牙 改成 teeth 时漏了并行链里的一处调用(run-tests.sh:172)。bash 只在运行时报一句
牙: command not found,退出码不受影响、汇总照印 ✓ —— 于是打网络那四条链的消融
(分段读 4 个、增量 2 个、只读要用的表 2 个、写路径 2 个)整整 15 小时一次都没跑过,
而 ./run-tests.sh 全 每次都是全绿。
bash -n通过 —— 命令名是运行时才解析的shellcheck -S warning退出码 0 —— 它不检查函数有没有定义- 发现它靠的不是任何一层检查,是人扫输出时看见那四行
command not found, 外加"全档 118 秒、比记录里的 195 秒短了一截"这个对不上的数
修完重跑:118 → 218 秒,那 10 个从没跑过的消融全部变红(它们本身是好的,只是从没被执行过)。 这一处至今没有会红的机制,只有一个要人去比的秒数 —— 补法是全档末尾数一下"断言有牙"的行数 够不够链数,还没做。
分发那条路:让最便宜的一档也能抓到它
tests/分发.test.mjs,0 秒、8 条判据、不打网络(走 看源表,它只读方案包)。
为什么单独有它:2026-08-18 加问答日志时,我在分发处写了 工具: name ——
而 name 在那个作用域根本不存在。后果不是报错,是 记这次 抛 ReferenceError、
日志一行不写,而查询照常返回正确结果,人和模型都看不出异常。
抓到它的是 tests/protocol.test.mjs(打网络、几分钟)。而在那之前,
17 秒那档里没有任何测试执行过 tools/call 这条分发路径:
资源/scope 不起子进程,通知 起了但只调 tools/list。
于是分发层的错只能等最慢的那档来抓。
回放验过:把 工具: name 放回去 → 这个测试当场红(tools/call 超时 20 秒),还原后回绿。
它守的是分发这一层,不是任何工具的业务判据:tools/call 通不通、返回体没被改坏、
分发处的钩子真的产生了副作用(只验返回值的话,钩子静默抛异常看不出来)、
改过名的老参数当场顶回去、没有的工具名报错并列出有哪些、ping 回空 result。
ping 单说一句:它是探活,不能掉进兜底的 -32601 —— 客户端拿它判断连接死活,
「不支持这个方法」和「进程已经没了」在客户端那儿是同一种表现,而 server 其实好好的。
一般化的教训写进全局规则了:每条便宜的验证路径必须真覆盖到那条代码 —— 一条只有最贵那档才执行到的路径,等于它的错要等最久才看得见。
资源列表只列最近几个(lib/资源.mjs)
resources/list 原来列 30 个,其中 28 个是历史导出快照。客户端拉这个列表是想知道
「这儿有什么可读的」,答案不该是二十行时间戳。现在每一类最多列 3 个
(INVENTORY_RESOURCE_LIST_MAX 可调),实测 30 → 9:三个静态资源 + 最近 3 份导出 + 最近 3 份周报。
不是怕列表失控 —— 清旧导出 每个目录本来就只留 20 份,磁盘那头有人管。是信噪比。
截断成立的前提是「截掉的还够得着」,两条路缺一不可:完整清单在 inventory://导出
(全部文件名、大小、路径),单个文件走 inventory://导出/{文件名} 模板、名字可补全。
resources/read 从来不受列表限制,任何一个 uri 都照读。哪天把这两条撤了,截断就从
「合理的降噪」变成「列表里没有 = 没有这个文件」—— tests/资源.test.mjs 的 ⑯ 和 ⑭ 是一对,守的就是这个。
问答日志:把评测集从「我造的」换成「人问的」
lib/问答日志.mjs + ./问了什么.mjs。每次工具调用记一行 JSONL 到
~/.cache/inventory-mcp/问答日志/2026-08.jsonl,按月切、只留最近三个月。
为什么要它:2026-08-18 之前一条查询日志都没有 —— 连「命中 0 发生过几次」都不知道。
而 tests/召回.test.mjs 量的是 189 个真型号 × 9 种机械扰动,那 9 种是我造的,不是人问的:
96% 说明解析器不怕大小写和连字符,说明不了「人问的东西查得到」。这份日志是把评测集换掉的原料。
./问了什么.mjs 这个月:按工具/资产类型/档位分布 + 耗时和返回体分位数
./问了什么.mjs 2026-07 指定月份
./问了什么.mjs --没答好 只列命中 0 和出错的 —— 这些才是拿去改召回的样本
三条硬规矩,都在 tests/问答日志.test.mjs 里有判据:
- 参数原样记,不补默认值 ——「他没填」和「他填了默认值」是两件事,补了就分不出模型是不是漏填。
- 失败也记 —— 命中 0 和「压根跑不通」是两类问题,混在一起就分不出「这个货真没有」和「这条链坏了」。
- 不许拖慢查询 —— 追加写不 await、异常整个吞掉;日志坏了不该让人查不到货。
记的收口在 server.mjs 的工具分发处,一处覆盖十三个工具 ——
分散到各个工具里记,迟早有一个新工具忘了记,而「少记了一个工具」没有任何地方会报。
顺带补了 tests/不膨胀.test.mjs 的一个洞:它原来只扫五个写死的文件名,
所以新加一个带 mkdirSync 的模块它根本看不见——目录悄悄长大,而这条守着
「会长大的东西必须有清理」的判据照样绿。改成扫全部源文件之后,加这个日志时它当场拦了我一次
(日志目录 不在「该有的清理」名单里),登记 清旧日志 才放行。
一个防漏配的检查自己有个漏配的名单,是这套东西里最讽刺的一种失效。
返回体里什么占地方(2026-08-18 实测)
一次 查库存 返回 8840 字符,拆开之后大头不在我以为的地方:
5487 字符 62% 明细(10 行) ← 其中 物料键 一个字段就占四成
1394 字符 16% 口径 ← 源表链接 645(答案覆盖 6 个库房)
652 字符 7% 怎么答
319 字符 4% 按库房
其余 13 个字段加起来 不到 11%
改了三处,降到 7480 字符(省 15%):
明细按梯队排。 改之前主明细一次都没排过序(直接切前 N 行),第一眼看到的可能是
二梯队的大库存 —— 而二梯队「需协调(有项目占着)」,一梯队才是「自由调用」。
第一行就是人当成答案的那一行,它必须是最容易真拿到的那批。
和 按库房 用同一套次序(byTier),两处不许各排各的;同梯队同量时按型号定死次序,两次跑输出能 diff。
对话里的明细不带 物料键。 它就是 资产类型|品牌|型号|库房 拼起来的,而那四列本来就在 ——
在对话里等于把每行最长的字段重复一遍。落文件那份还带:那是给人填占用记录用的,
四个字段自己拼一次就会拼错(分隔符、空格、大小写都得一字不差)。
源表链接的标签去重(闵行/闵行: → 闵行:)。链接本身不动 ——
它已经只给「你这个答案覆盖的库房」,不是「这次读过的所有表」。
放宽代价表:按代价挑,不按捞得多挑(lib/substitute.mjs)
查不到货时工具会「放宽一项」再查一遍。原来挑哪一项是按捞得最多挑的,
而捞得最多的恰好是代价最大的那一项 —— 客户要 100G LR4 单模,放开 std
立刻报出 8,932 根 SR4 多模,规格逐项「相同」、数字很好看,插上不亮。
按收益挑等于优先推荐最危险的那一项。
方向 表说的是「候选比需求高算不算能用」;这张表说的是「查不到时把这一项整个不约束,
风险有多大」—— 两件事,两张表:
| 能放 | 慎放 | 不能放 | |
|---|---|---|---|
| 光模块 | form wave |
media(单模↔多模) |
rate std |
| 内存 | speed rank |
cap |
gen(DDR4↔DDR5) |
| 硬盘 | gen |
cap form(2.5↔3.5 寸) |
bus(SAS↔NVMe) |
| 网卡 | — | chip |
rate ports |
wave 判「能放」是跑出来的,不是照着道理拍的。 拿全部光模块按 std 分组数波长:
23 个 std 里只有 2 个对应不止一个波长,而那两个都是同一个波长的两种写法 ——
FR4 是 1300(597 根)/1310(64 根),SR4 是 850(15,872 根)/840(8 根)。
没有一个 std 跨到真正不同的光学波长上。所以放开 wave 捞回的是写法差异,不是另一种货。
(单模↔多模那条线由 media 管,那一格是「慎放」。)
行为:只有「能放」的会被自动放宽;「慎放」的摆出来、点名要人确认;
「不能放」的每条带一句「别拿它当答案」,排在最后。
没定过代价的槽位一律按「不能放」算(放宽代价是() 的兜底)——
少定一格不该变成「默认可以放」,tests/substitute.test.mjs ㉝ 拿解析器的 core 逐项对。
拒答:这套工具唯一能说「没有」的地方
在这张表之前,系统说不出「这个货真的没有」:零命中一律被解释成「匹配没配上」,
提示词里还写着「不许说没有这个货」。于是要 100G LR4 单模、库里只有 SR4 多模 时,
工具报「有 8,932 根」。能说「没有」和敢说「有」是同一件事的两面 ——
一个永远说有的系统,说有的时候也没人信。
判据只有一条:捞到货靠的是放开哪一档的槽位。三档全捞不到 → 还是「匹配没配上」;
只有「不能放」的能捞到 → 「没查到」那句里明写 这一次可以直说,怎么答 里那条
「不许说没有」同时给出唯一的例外口子。实测:
槽位 rate=800G,std=SR8,media=SM,wave=1310
→ 「能捞到货的那几项全是不能放的(std)… 这一次可以直说「这个规格库里没有」」
(放开 std 有 18,377 根,但那是另一种货)
记下决定:人拍过的,下次不用再问(lib/决定.mjs)
这是这套东西唯一会随使用变准的部分。在它之前:客户拍了「QSFP28 就用 QSFP 顶」, 下一轮从零开始再问一遍 —— 同一个问题每周问一次,每次答案还可能不一样。
为什么不写进方案包的 modelAliases。 那张表管的是「这两个写法是同一款货」,
写进去会把两边的库存合成一个物料键。而「A 能顶 B」不等于「A 就是 B」:
QSFP-100G-SR4-MM850 能顶 QSFP28-100G-SR4 用,但它们是两款货、两个键、两笔库存。
混进去的后果是库存数当场变形,而且不报错。所以另起一份(决定.json,跟着代码走,
INVENTORY_DECISIONS 可改),只影响推荐,不影响「有多少」。
四条护栏:
- 依据和「谁拍的」不许空 —— 这条决定将来要有人认账。和「品牌补充」同一条规矩。
- 日期由调用方给,模块自己不取 —— 取了就没法跑两遍验幂等。
- 同一对型号只留一条,判重方向不敏感(人拍的是「这两个能不能互顶」)、
写法归一之后比(
QSFP-100G和qsfp 100g是同一对)。改口时把上一版留在改过里: 「上周说能顶、这周说不能顶」本身就是要给人看的。 - 拍「不能顶」的候选不删掉,只标 —— 删了人看不出「这个我们判过」,下次还会有人再问。
读不出来(文件坏了)时 读决定 直接抛,不当空表 —— 当空表等于把人拍过的决定悄悄清零,
而下一次查询只表现为「又来问一遍」。但 挂决定 那条路吞掉异常:决定表坏了不该让查询整个失败。
查SN:大了就给文件,不往对话里倒
源表每条记录就是一根货、带 SN,聚合成物料键时那一列被压掉了 —— 查SN(lib/sn.mjs)
把它还原回来。三件事是这个工具的全部内容:
① 快照里的写法是归一前的,得折回去。 SN 明细来自 收SN() 那份 SN\t资产类型|品牌|型号|库房,
后三段是源表原样(闵行 和 闵行库房 是两个值,SAMSUNG 和 Samsung 也是)。
所以先用 norm.rows 的 原始(记的正是 品牌0|型号0)加 PLACE 表建一张反查索引。
少了这步,查「闵行的 128G 内存」会漏掉源表写「闵行库房」的那 5,838 根,而漏掉的部分不报错,只是数偏小。
一个原始写法对上两个物料键就抛——那说明归一不是个函数了,这时按哪个算都是猜。
② SN 明细跟着 load() 的返回值走,不去读磁盘上那份快照。 快照是 fire-and-forget 写的,
冷读刚返回时盘上还是上一份;热态命中压根不重写。从结果里带出来(约 10MB),
SN 和 norm 就一定是同一次读的,在库 = 有SN + 缺SN 才对得上账。
③ 「在库根数」和「有 SN 的根数」永远分开报。 源表 SN 列有空着的,两个数不相等;
合成一个的话,人会拿 SN 条数当库存数用。对不上时返回里是 ⚠ 有货没有 SN。
另有 认不出的(全库口径):对不回任何物料键的 SN,正常是 0,非 0 说明归一和收快照两边
挑走的行不一致——这个数不能吞,吞了那批货就此从所有 SN 查询里消失。
超过 最多列几条(默认 50,INVENTORY_SN_INLINE 可调)就不在对话里列了,改写一个
带 BOM 的 CSV,返回里只给路径和按物料键的分组计数。去处按「谁要这个文件」分两个:
人明确要(一定要文件: true)→ 桌面;工具因为太大塞不进对话自己落地 → ~/.cache/inventory-mcp/导出/。
后者是工具为了省 token 的内部动作,人没要过,不该占他桌面 —— 混成一件事的后果实测过:
一下午测试跑出 20 个 CSV 糊在桌面上。两个目录各留最近 20 份,只删自己按格式生成的名字。
50 是按 token 定的:一个 SN 约 5 token,50 个约 250,整个返回体和一次普通 查库存
一个量级(约 1,400 token);再往上就开始挤后面几轮的余量,而人要一千个 SN 时,
他要的本来就是那个文件,不是在对话里滚屏。文件只在超上限或显式 一定要文件 时才写 ——
平时不写,因为写了就得有人清,而没人会去清一个每次查询都长出一个文件的目录。
零命中单独说一句「这不等于库里没这个货」:条件比的是归一后的写法,
干返一个 0 会被模型报成「没有这个货」。
看变动:真进出按 SN 算,键级变化是噪音
「有什么变动」这个问题,口径块里的 SN对比 答不了 —— 它比的是「上一次任何人跑过这个
MCP」,而任何一次查询都会把基线覆盖掉(包括 agent 自己随手查的那次)。所以它几乎
永远显示「一致」。真正的基线是每周四 ./周更.mjs 存的 周基线/YYYY-MM-DD.tsv,
看变动 读的是那个,只读不写,不会动基线。
键级变化会骗人,这是这个工具全部的设计压力。 实测 08-06 → 08-13:
| 口径 | 数字 |
|---|---|
| 键级:消失 30 个键 / 新增 48 个键,移动45号楼一口气少 3,573 根 | 看着像出了大事 |
| 按 SN:真出库 32 根 / 真入库 57 根 | 实际动的 |
| 只是改了写法 | 11,336 根 |
差额全是同一批货把空品牌补成了 CLT / 光迅 / H3C —— SN 一根没动。只筛移动45号楼时更
干净:75 个键变了,真进出 0 / 0。所以返回体的顺序是死的:真进出打头,
⚠ 别把改写法当出入库 跟上,键级的「真消失/真新增」是摘掉改写法之后剩的那些
(解释改名(),lib/weekly.mjs)。判据是 SN:一根 SN 两边都有就是没动,不管键怎么写。
一个键少 5 根、其中 3 根只是改写法 → 报「真少了 2 根」,不是 5 也不是 0。
tests/weekly.test.mjs 消融 3 拿掉这一层就必须变红。
要一份不存在的基线会报错并列出现存的,不返回一个「没变动」—— 「没有这个基线」和「这段时间没动过」混在一起,人会以为账是平的。
看源表:这批数从哪张表、哪一列来的
这个工具是被一次真实的答错逼出来的:有人问「128G 内存的所有 SN」,我去翻了台账那三张表 (存量明细/线下台账/占用记录)、看到没有 SN 列,就答「不存在 SN 数据」—— 而 MCP 根本不读台账,它读方案包里那些源表,那里每条记录就是一根 SN。 光看得见结果、看不见来源,就会拿错的地方当证据。
张数以方案包为准,别在文档和判据里写死:2026-08-14 删掉 CPU 方案后从 34 变 31,
tests/protocol.test.mjs 里那两条写死 34 的判据当场变红 —— 现在改成从方案包现算。
只读方案包(plans.json),一次网络都不打:要的是「配置说这张表怎么读」,
不是「这张表现在有多少行」——后者是 查库存 的事。
map 里那些「源表列名 ≠ 我们的字段名」的映射必须显式列出来。 实测有 4 张的 SN 不叫 SN:
SN: { 有: "31/31 张",
列名不一样的: [ "网卡·山西/山西广灵:叫「外部SN」",
"硬盘·山西/山西台账:叫「外部SN(必填)」",
"内存·临港9号楼/B-2项目-9号楼资产表:叫「CMDBSN」", … ] }
只报「有 SN」会让人去源表里找一个不存在的列名。同理「货位」只有一部分表有, 而这 11 张里叫「货架-区块」「储位」「箱号」的都有。
默认只给按字段的汇总,逐表明细要显式要(实测汇总 2,336 token、逐表 8,580)。
两个名单都截——不截的话汇总本身 4,227 token,比它想省掉的明细还离谱;
截了必须报还剩几张(tests/源表.test.mjs 判据 ⑬)。
数据从哪来:两条路,INVENTORY_SOURCE 切
direct(默认) ledger(INVENTORY_SOURCE=ledger)
31 张源表(28 张 + 临港移动7号楼 3 张) 飞书 线下台账 (340 行)
每张 1~2 次并发调用:列宽有缓存就直接只读一类 人每周从油猴导出件粘贴
冷启动约 11 秒 / 热态约 2.4 秒 2.5 秒
约 16 万根 / 360+ 个物料键 12.9 万根 / 325 个物料键
└──────────────┬──────────────┘
库房名统一 → 品牌归一 → 型号归一 → 按物料键聚合
物料键 = 资产类型 | 品牌 | 通用型号 | 位置(粗到库房)
两条路共用同一套归一(lib/ledger.mjs 的 normalize()),差别只在数据从哪来。
直读那条多三步:按方案跑 build_stock 的筛选规则 → 按 SN 去重(lib/dedup.py)→ 一条 SN 记一根聚合成台账形态。
默认走 direct 的判据是它又快又全:热态约 2 秒和读台账的 2.5 秒相当,多 3 万多根,
而且差额能解释 —— 临港移动7号楼那本线下台账没收,其余是一周的滞后加人工环节漏的。
ledger 留着当退路,不删。
这里不写死具体根数:源表一天能被改好几次(2026-08-13 当天 09:32 / 09:48 / 13:16 各一次), 写死的数字第二天就是错的,而过期的精确数字比没有更误导。要当下的数就跑一次,口径块里有。
热态那 2 秒是并发查 9 本文档的 revision;全没变就用进程内缓存,数据始终在本地,
变了才重拉。用 revision 不用 latest_modify_time —— 后者实测有 2~5 秒延迟,刚改完查会说没变。
方案包也算「版本」的一部分(方案包指纹()):改了筛选规则、加减一张表、改了列映射,
飞书那 9 本文档的 revision 一个都不会变,缓存却该失效。实测过:砍掉一个方案(少 9 张源表)
之后再查,热态照样答全部源表的数(当时 34 张 / 162,514 根,现在方案包里是 31 张)。而人看到 ⚠ 筛选列冒出新取值 之后要做的第一件事,
正是改方案包 —— 不算进去的话,改完还得等某张不相干的表被人动一下才生效。
指纹按内容算不按 mtime:拷贝/同步/恢复都会改 mtime,按 mtime 判会白重读 6~7 秒;
读不到时给固定值而不是随机数,不然每次查询都判成「变了」。
每次重读还会做一次逐单对比:把 16 万条在库记录压成 SN → 资产类型|品牌|型号|库房 存一份
(~/.cache/inventory-mcp/sn-snapshot.tsv,约 11MB,每次覆盖),和上一份按 SN 求差,结果进口径块:
少了几根(出库)、多了几根(入库),以及**「SN 没动但型号写法变了」几根**。最后这类单独报是因为
它按 SN 求差集查不出来(两边都有),但聚合后长得和一进一出一模一样 —— 这是「两周库存型号对不上」
最常见的成因。SN 明细落在 sn-diff.json,口径块里只放数字和前几个物料键(省 token)。
读旧快照和网络请求并行发起、写新快照不等待,所以不占关键路径;热态压根不碰磁盘。
冷启动还有一层磁盘上的结构缓存(~/.cache/inventory-mcp/schema.json):存 wiki→文档 token
和每张表用到的列排到第几列,这两样几乎不变,命中时每张表省掉「先读表头」那一趟。
缓存里还记着「这张表要分段、段长收敛到几行、上次多少行」。缓存过期不会读错 —— 正文里自带表头行,
每次都拿它现验一遍缺列,对不上就丢缓存走慢路(验表头(),tests/direct.test.mjs 判据 ⑮~⑱ + 消融 4 守着)。
缓存里的段长只许比当前策略小、不许比它大(夹段长()):不夹的话,每段格子 调小之后
已缓存的表照用旧的大段长,新常数对它永远不生效,而且读得对、守恒过、检查全绿,只是慢一倍。
增量重读:只重读 revision 变了的那本
原来任何一本文档变了都全量重读 31 张表。现在按文档级 revision 决定重读哪几本,没变的直接用
盘上存着的行(~/.cache/inventory-mcp/rows.json,实测 36 MB)。判据和热路那条是同一个
(只认内容变没变),只是粒度从「全都没变才用」细到「这一本没变就用这一本」。
全部都变了(=全量) 14.5 秒 复用 0 张
变了最大那本(6 张) 12.4 秒 复用 25 张
变了最小那本(1 张) 7.5 秒 复用 30 张
一本都没变(热态) 2.8 秒 复用 30 张
收益完全取决于变的是哪本 —— 最大那本含闵行光模块那张 8 万行的,只省 2 秒。那 7.5 秒里真正
读表的只有一张,其余花在查一轮 revision(约 2 秒)+ 从盘上反序列化几十 MB(23 秒,
原先估成 0.30.5 秒,估错近一个数量级)+ 筛选去重 1.4 秒。
落磁盘不留内存:33 万行留内存实测占堆 168 MB,而这个 MCP 是 WorkBuddy 拉起的单例长驻进程, 留内存就是一直占着。落盘换的是内存不涨 —— 而且多覆盖一个内存版覆盖不了的场景: WorkBuddy 退出再打开时进程是新的,原来必然全量冷读,现在拿盘上的 revision 问一轮就能增量。
三种情况整份作废退回全量:方案包指纹变了(那 9 本 revision 一个都不会变,但每张表怎么读全变了,
没有「哪本变了」可言)、force、盘上没有或指纹对不上。盘上没记 tok 或 revision 的一律重读 ——
「不知道它有没有变」和「它没变」是两回事。
变了的那本重读失败时拿不到旧行(复用 对变了的文档返回 null),照常走 缺表 那条显式降级:
悄悄用旧行的话,人拿到的是「看起来完整但已过期」的数,比缺表严重得多。
行缓存只存读成功的表 —— 存一份空的等于把「这次读不到」固化成「这本就是没货」。
判据只有一条,但它是这个功能的全部安全性:增量结果必须和全量逐个物料键一模一样
(tests/增量.test.mjs)。不能拿「守恒过了」当判据 —— 复用了一本其实变了的文档,
总数少一截而每步守恒照样绿。造场景靠 INVENTORY_FAKE_CHANGED=<token片段>,
每次调用现读环境变量(用法就是「同一个进程里先全量跑一遍、再假装某本变了跑第二遍」,
读死的话第二遍改不动,测试只能起子进程、每遍都得全量打一次网络)。
只读问到的那类资产的表 + 在途去重(2026-08-17)
问一句「光模块够不够」,原来会把 31 张源表全读一遍。光模块只占 7 张,另外 24 张
(网卡 9 + 硬盘 9 + 内存 6)一行都用不上。更糟的是模型问「这两款各够不够」时会
同时发两个 查库存,而进程内缓存只在跑完那一刻才写,所以两个各读一整遍。
实测(改之前):
两个 load() 并发 55.0 秒,各自读回 332,763 行 ← 各读各的,双倍 API 调用
等第一个跑完再来第三个 2.0 秒 ← 这才走缓存
改完之后:
只问光模块(冷) 42.6 秒 读 7 张 / 237,109 行 / 119,090 根
再问网卡 8.7 秒 读 9 张,行缓存累计 16 张
两个硬盘并发 8.6 秒 只读一遍,两边拿到同一个结果对象
接着全量 9.8 秒 31 张里 25 张直接复用 —— 前面几次只读一类顺手把缓存捂热了
全量之后再问光模块 2.0 秒 走「全部」那份缓存,不重读
哪些工具只读一类,判据是「这个工具的答案会不会用到别类的行」:
查库存 / 看分布 / 找替代 / 看有哪些型号 只读一类;
查SN(SN 可能属于任何一类)、看变动(逐单对比是全仓口径)、看筛选(要全仓的取值分布)必须全读。
三条规矩,每条都是为了挡住一种静默错:
只读一类一个基线都不许滚。 拿只读了 7 张表的 SN 快照覆盖全仓那份,等于把「这次没读网卡」
固化成「网卡的货全没了」—— 下次全量一比,二十几万根全算「多了」,而每一步守恒照样绿。
所以只读一类只回答问题、不承担监控职责:不写 SN 快照、不滚命中基线、不写差异文件,
也不拿旧基线做对比(半份数据 vs 全份基线,归零 会对每一张没读的表报一次警)。
口径必须自报。 少了那句 ⚠ 这次只读了部分源表,在库总根数 就从「全仓」悄悄变成
「这一类」,而数字长得一模一样,模型拿它答「一共多少根」就会答小一个数量级。
行缓存是叠加不是覆盖。 只读一类只带回 7 张表的行,整份覆盖会擦掉另外 24 张, 下次问网卡还得全量冷读 —— 一个本来省时间的改动反而让别的查询变慢。
「全部」那份缓存能答任何窄问题,反过来不行(超集,答案层照样按类型筛)。 没有这条不对称,查过全量之后再问光模块会白读一遍,优化在最常见的场景里反而变慢。
两个逃生开关,同时也是消融的注入点(一个东西关不掉,就等于没法证明它在起作用):
INVENTORY_NO_NARROW=1 退回全读、INVENTORY_NO_INFLIGHT=1 关掉在途去重。
跨方案剔重(同一个 SN 既算网卡又算硬盘)是跨资产类型的,只读一类时看不见跨类的撞号。
实测当前数据是 0 条,所以今天不影响任何数字;哪天它非零了,全量那条路照样会报
⚠ 筛选规则有重叠。
你实际等的是几秒(2026-08-17/18 实测)
先说一个我自己搞错过的区分:所有「冷读 41 秒」的数字都是在空的临时目录里量的(为了不碰真缓存), 那是新机器第一次跑的场景。你的场景是 WorkBuddy 重启——进程是新的,但盘上行缓存还在:
新进程 + 真缓存,问光模块 3.8 秒 ← 7 张全复用,读表 0 秒
同进程再问一次 1.9 秒
空目录冷读 7 张(新机器才遇到) 41.1 秒
那 3.8 秒拆开之后,88% 集中在一处:
2.00 秒 问 9 本文档的 revision(一趟网络,已经全部并发了)
0.14 秒 起 python 去重 33 万条
0.08 秒 JSON.parse 那 37.6 MB 行缓存
0.03 秒 从盘上读那 37.6 MB
0.01 秒 import
那 2 秒压不下去,量过了:lark-cli 起一次子进程 + 一次网络往返本身就是 12 秒的地板 ——
9 个 2 秒,和单发一个一样)。
所以「合并成批量调用」这条路是死的,metainfo 并发和 1 次 metas/batch_query 一样快(各 1batch_query 还只有 latest_modify_time(实测有 2~5 秒延迟)没有 revision。
所以把它挪出用户的等待路径:信任期
MCP 启动 1.5 秒后后台热一遍,之后每小时后台核一次;查询直接用上一次核过的那份,一趟网络都不打。
第一次(真核) 3.7 秒
信任期内 0.0 秒
后台定期核(绕过信任期) 2.1 秒
force(导台账 / 周更) 42.0 秒 ← 不受影响,永远真核真读
代价是数据最多陈旧一小时(人拍的 2026-08-17)。所以:
- 陈旧必须看得见:口径块里带
数据核对于: 2026-08-18 00:00:06(3 分钟前核对的,之后源表有没有被改过这次没查)。 悄悄用一小时前的数是这套东西最不能出的错 —— 每步守恒照样绿、总数照样对,只有真去核源表才看得出来。 - 后台那趟不吃自己的信任期(
_绕过信任),否则它每次命中缓存、永远不去真核,信任期就成了一个永不过期的谎。 INVENTORY_TRUST_MS=0退回「每次查都核」。
再往下要快,只剩绕开 lark-cli 子进程直接发 HTTP(省掉那 1~2 秒地板),代价是自己接管飞书的鉴权和 token 刷新 —— 现在这些全是 lark-cli 在管。
读表为什么是现在这个速度(2026-08-15 实测)
读表这一步已经并发到头了,再想快只能少读。 三组交替实测:
① 单张表切几段 闵行光模块 84,952 行 × 9 列,每档三轮取中位
5 段 7.7 秒 · 17 段 3.7 秒 · 34 段 3.2 秒 · 68 段 4.2 秒(掉头)
② 元数据怎么发 串行(拿行数→再发段) 20.1 秒 · 段并发 7.8 秒 · 元数据与段同批 5.0 秒
③ 全局并发几路 31 张 33 万行:4 路 28.5 秒 · 8 路 13.0 秒 · 32 路 14.2 秒
并发能重叠的是「等」,重叠不了「传」:请求的时间 = 等(往返延迟)+ 传(占管道)。
并发把多个「等」叠起来,但字节数不会因为同时发就变少。所以曲线是先降后平再微微上翘 ——
③ 里 8 路就填满了,32 路反而慢一点(几十个 lark-cli 进程抢 CPU);① 里 68 段同理。
INVENTORY_CONCURRENCY 默认取 8,每段格子 取 45,000(17 段),都是这么定下来的。
段长不取最快的 34 段,是拿 0.5 秒换频控余量:调用次数随段数走(5 段约 36 次 / 17 段约 48 次 / 34 段约 65 次),而最窄一档 100 次/分,撞线的代价是十几秒。
每次读都跑的五道检查
数字对不对,靠的不是「算得仔细」,是每一类错都有一条会红的检查。五道按严重度排:
| 检查 | 挡什么 | 不挡的后果 |
|---|---|---|
| 筛选命中率 | 某张表的状态列写法被改了(「在库」→「在库中」) | 那张表一条都命中不了、几千根货一次性消失,而每一步守恒照样绿(进出都少了同样多) |
| 源表读失败 | 读不到其中几张 | 那几个库房的货凭空消失,人以为「那儿没有」而不是「那儿不知道」 |
| 坏件 / 坏字 | 型号带「坏」的、规格列是 [object Object] 的 |
坏件被归一合进好件算成可用库存;坏字污染物料键 |
| SN 逐单对比 | 说不清「少了 106 根」是出库、改写法,还是读错了 | 每周对账靠猜 |
| 型号写法不像这一类(只报不拦) | 整批货被归错资产类型 | 错得离谱但所有检查全绿 —— 926 根光模块被判成硬盘那次,总根数对、每步守恒、SN 对比正常、命中率正常,因为货确实都在、只是挂错了类。唯一能发现的方式是人扫一眼物料键列表,觉得「硬盘怎么叫 QSFP112-400G-DR4-SM1310」 |
「归零」留在机器里,「变了多少」交给模型。 这条线是 2026-08-14 挪的:原来机器还判
「命中率掉超过 20 个百分点 = 明显下滑」,那个阈值是拍的、标着未验证,而同一个变化在三种情境下
意思完全不同(人改了规则 / 源表的列被改了 / 真进了新状态的货),机器分不出是哪种。
现在机器只报 {表, 命中率: "99% → 12%", 差几个点},算不算异常由读它的模型判(看筛选)。
命中率归零是零误报的硬信号:有效行还在却一条都没命中,不可能是正常业务。 所以它是绝对判据,不需要基线 —— 曾经写成「上次命中 > 0 才报」,于是一张 从来就命中不了的表永远静默:第一次没基线不报,第二次基线里记的也是 0,条件永远不成立。 那正是这个探测器要防的那件事。报法:
⚠ 有源表的筛选一条都没命中:
网卡(导入) · 闵行/闵行网卡在库清单信息:读到 4575 行有效数据,但筛选一条都没命中(上次命中 2841 行)
这几乎一定是那张表的状态列写法改了,不是货清空了。去核对源表的筛选字段,别按下面的数字下结论。
**这条会一直报到修好为止** —— 有异常就不滚命中基线,不然警报会把自己吞掉。
警报不许把自己吞掉。 命中率基线(table-hit.json)原来每次读都覆盖,于是:
某张表被改坏 → 报一次 → 基线滚成「命中 0」→ 从此再也不报。现在
该滚基线() 只在这次干净时才让它滚;有归零或下滑就保持不动,异常一直报到修好。
唯一的解除方式是人显式认下:./周更.mjs --认下筛选(--dry 时不认)。
不给自动认领的口子——能被自己清掉的警报等于没有警报。
同一个病 SN 快照也有:它每次读都覆盖,所以口径块里的 SN对比 比的是「上次任何人跑过
这个 MCP」,几乎永远显示「一致」。那半边的解法是 看变动 读周基线(只读不写)。
绝对命中率不是判据。 实测三张表常年低命中,规则都是对的:
| 表 | 规则 | 这一列的实际取值 |
|---|---|---|
| B-2临港9号楼 · 光模块(7%) | 资产状态 = 库房 |
在线 52,341 · 库房 4,280 · RMA出库 1,330 · 「在线,无对应关系」853 |
| JYJY临港9号楼 · 光模块(15%) | 同上 | 在线 48,777 · 库房 10,575 · 调拨出库 7,910 · 待定 50 |
| 移动7号楼 · 内存(20%) | 出库/在库 = 在库 |
出库 23,689 · 在库 6,031 · 故障 32 |
那几张是全量资产台账,大部分行是已上机在用的设备。定任何绝对阈值都会把它们误报成异常。
第五道:型号写法不像这一类(类不对的键,lib/ledger.mjs)
判据是「别的类解析出的核心槽位比自己多几个」,阈值 2,是量出来的不是拍的 (2026-08-16,374 个真实键 + 那批历史错键):
真实键 差 ≤ 0 的 372 个 · 差 1 的 2 个 · 差 ≥ 2 的 0 个
历史错键 硬盘|QSFP112-400G-DR4-SM1310 自己 1 槽位 vs 光模块 5 → 差 4
正常硬盘 硬盘|7.68T NVMe U.2 Gen4 自己 4 槽位 vs 光模块 0 → 差 -4
中间空了两档,所以 2 有真实余量。判据 ㉕㉖㉗㉘ + 消融 9,其中 ㉘ 专门守「阈值不许放松」。
它抓的不只是一种成因:跨方案剔重把货判给先出现的方案、方案包的 配件类型 筛错、
某张表的 map 列映射错、源表里有人把货填到错误的分类下 —— 四种现象一样。
第一版判据「自己解析出 0 个槽位」不成立:硬盘的解析器把 400G 当成容量、解析出 1 个,
历史错键一个都抓不到。这正是「四类之间只有 rate 那 5 个值撞车」在这里的表现 ——
那个结论按写法种数算够用,用在「跨类比槽位数」上就不够,撞一个就把条件破坏了。
只报不拦。 它是启发式的,和「筛选命中率归零」那种零误报的硬信号不是一档。
哪些维度错了查不出来
五道检查覆盖的是「有第二来源可以对照」的那部分,不是全部。 一个维度错了之后 总量还是守恒的,能不能发现取决于源表里有没有第二个角度去看它:
| 维度 | 错了会怎样 | 有没有第二来源 | 现状 |
|---|---|---|---|
| 资产类型 | 整批货归错类,总数守恒 | 有 —— 型号写法能反推 | 第五道检查 |
| 库房 | 整批货挪库房,总数守恒 | 没有 —— 物料键的库房段来自方案包配置(region),而源表里被映射成「库房」的那列装的是货位(403库房 / CK2-H03-A01 / 库房303),不是库房名 |
只能靠配置对 |
| 品牌 | 整批货换品牌,总数守恒 | 没有 —— 源表只有一份 | 只防「两个方案的对照表打架」(loadBrandMap 会抛),不防「四个方案一致地错」 |
| 在库状态 | 筛多了 → 货凭空变多 | 没有 | 命中率只抓「筛没了」(归零);筛多了命中率反而涨,看着更健康 |
最后一行是这张表里最该记的:「筛多了」比「筛少了」隐蔽。人对数字变大的警惕本来就比 变小低,而机器唯一的探测器正好在往反方向看。
表名当第二来源试过,不行:31 张表里误报 3 张(B-2项目9号楼 这种表名在 PLACE
别名表里登记的是 B-2临港9号楼,差两个字),10% 的误报率做成每次跑的警报只会变噪音。
这套接口不会告诉你发生了什么
飞书多维表格的写接口有一个一贯风格:说成功了,但你没法从返回值知道发生了什么。 2026-08-15~16 两天里踩到五次,放在一起看比分散在各处的注释有用:
| 命令 | 它不告诉你什么 |
|---|---|
+record-batch-create |
回 ok:true,records 是空数组 —— 建了几条、record_id 是什么,都没有 |
+record-batch-update |
不检查 record ID 存不存在。拿一个不存在的 ID 更新,照样回 ok:true,表里一个字没变(帮助里自己写着这句) |
+table-create |
返回体里没有 table_id,只回 {fields:[…]}。得建完按名字查回来 |
--dry-run |
只回显请求体、不校验形状。错的和对的都回 ok:true —— 拿它验过反而会以为没问题 |
| 写公式字段 | 被 ignored_fields 静默吞掉,没有任何提示 |
所以「写完必须回读、不认返回值」在这个项目里不是保守,是唯一可行的做法。
2026-08-16 那次事故正反两面都验到了:回读挡下了一次(./导台账.mjs 补跑时报「值对不上 0 条」),
而崩在回读之前那次它没挡住 —— 所以现在写失败也要走完读回(见 lib/ledger-write.mjs)。
请求体的形状只有一份:创建请求体() / 更新请求体() 在 lib/ledger-write.mjs,
契约测试用的就是这两个函数。原来测试里手写一份 {update_records:{id:{数量:100}}},
于是它抓得住「lark-cli 又改契约了」,却抓不住「我们的代码拼错了」——
而 8-16 真炸的那次正是后者的相邻情况(契约变了、我们没跟上)。两份字面量各自成立,
谁改了另一份都不知道。守这条的是 写路径 那两个消融:把生产那两个函数换成错形状,
判据必须红(实测 ABLATE=1 报的就是当天那个 800010701 Request validation failed)——
要是哪天有人在测试里又手写一遍,消融就不再红,run-tests.sh 当场报「一条判据都没让它红」。
撞频控:一个显式的错误,被压成了要靠猜的
飞书按 API × 应用 × 租户算每分钟速率,最窄一档 100 次/分,撞了回 HTTP 429 + code 99991400。
这是个显式错误,可它一路传上来只剩「这次失败了」,和「表读不到」「登录态过期」长得一模一样。
代价是实打实的:2026-08-16 这一天为此多跑了四轮回归(每轮好几分钟), 每次判断「是限流还是真坏了」靠的都是间接推理——两次红的位置不同(#74 / #55), 位置随机才像环境问题,代码问题会稳定停在同一处。
现在三层都认它:
| 在哪 | 干什么 |
|---|---|
lib/direct.mjs 的 退避重试() |
撞了就退避重试(3 秒、8 秒),等的时候往 stderr 出声;退完还撞就打 撞频控 标记往上传 |
fetchAll 全军覆没 |
分开报:撞频控说「代码没问题,等一分钟再跑」,真读不到才说「查登录态和网络」 |
tests/消融.mjs 的 收尾() |
挂的每一条都是频控 → 退出码 3,run-tests.sh 印「⏸ 这轮没验成」,整套非 0 |
三处要点,每一处都是踩出来的:
退避只退两次、最多 11 秒,不是越多越好。 一开始写的 [3,8,20],然后意识到它会和调用方的
120 秒超时打架——31 张表每张退三次能把整轮读表拖到几分钟,于是「明确的限流」又变回
「莫名其妙的超时」,正是这次要修的那个问题的翻版。
「没验成」不许印成绿的。 只有「挂的每一条都是频控」才退 3,混一条真失败就退 1——
否则频控成了挡箭牌,把真的红盖成黄的。最要紧的是消融那条循环:牙() 只看「非 0 就算变红」,
一个根本没被执行到的消融照样被数成一颗牙、印「✓ N/N 消融都变红」。
是频控失败() 故意写窄。 曾想把「超时」也算进来(历史上撞频控就表现成 tools/call 卡满
120 秒),但那会把「服务器真的卡死了」也悄悄降级成「这轮没验成」。代价是:限流若表现成纯超时、
一个字都没留,这里认不出来,照旧报红。宁可多红。
INVENTORY_FAKE_RATELIMIT=N 造这个场景(前 N 次调用一律返回限流),INVENTORY_BACKOFF_MS
把等待压到毫秒。注入点挡在 退避重试() 那一层而不是 call() 里面——挡在里面的话
写台账那条路注入不进去,而那条一年只跑五十次,撞上了没人有第二次机会看现场。
我一开始说这条「要等真撞一次才验得了」。那是错的——同一个仓库里
INVENTORY_FAKE_FAIL、INVENTORY_FAKE_CHANGED已经用过两次这个模式,理由还是我自己写进注释的。 手上有现成的解法却没想起来用,比不知道更该记一笔。
筛选列冒出新取值(收筛选取值 / 比取值)
命中率抓不住的是慢性的那一类:有人把「在库」改成「在库中」,老记录还是老写法, 命中数只会一周一周地掉 —— 归零不触发,20 个点的阈值也够不着,等发现已经过了几周。 所以再加一条离散信号:走筛选之前的原始行,统计每张表每个筛选列的取值集合; 冒出一个基线里没有的取值就报。几乎零误报,代价是一次全量读多 0.3 秒、基线文件 7.8 KB。
⚠ 筛选列冒出了没见过的取值:
光模块 · 临港9号楼/B-2临港9号楼 · 资产状态:冒出新取值「在线,无对应关系」853 行
这批行现在没被算进任何一边。如果它其实是「在库」的另一种写法,那批货正在静默丢失;
如果是新的业务状态(借出、待检…),把它加进方案包的筛选规则再跑。
只报新增不报消失:某个状态这周没人用了是正常的。基线里没有这张表这一列时也不报,
不然第一次跑会把每种取值都当新的。同样受 该滚基线() 管——报了就不滚基线,一直报到处理为止。
读表失败不再一刀切地抛,只有全部源表都读不到才抛(那是登录态或网络问题,给半份没意义)。
只崩几张的话继续走,但完全读不到的库房必须显式占一行、根数写 null:
{ "库房": "七宝", "根数": null, "读不到": "网卡、硬盘、内存、光模块的表都没读到 —— 这里不是 0 根,是不知道" }
让它从数组里消失,人会读成「那儿没货」——这两件事差一个数量级。有表失败时不写进程内缓存, 否则下次热态会拿这份残缺数据当好的用,而那几本文档 revision 一个都没变,它会一直用下去。
造这个场景用 INVENTORY_FAKE_FAIL='方案/库房/表名的子串'——不给注入点,这条降级路径永远没法验证。
型号写法:两层归一
第一层字面(字面指纹 + 挑标准写法):只吃掉大小写、空格、连字符、点号的差异。
实测合掉 11 组,CX7-400G ⟷ Cx7 400G、128G ⟷ 128g、SFP-25G-SR-LC ⟷ SFP.25G-SR.LC 这种。
槽位解析救不了它们——解析器认的是规格语义,不认打字方式。
这一层选标准写法的判据不能沾根数:大写多 > 连字符多 > 长度长 > 字典序,四条全是型号字符串 自身的属性。原来的规则里有「根数多者胜」,而根数每周都在变——标准写法是物料键的第三段, 它一变,台账下拉框里选过的值就悬空了。
第二层槽位:解析出封装/速率/标准/介质/波长这些槽位,核心槽位全等才合。这层的判据 运行时从油猴脚本现抠,不复制。
直读这条路踩过的四个坑(都在代码里挡住了)
| 坑 | 挡法 |
|---|---|
闵行光模块那张整表读报 90221 超 10MB |
所有表都只读 map + includeRules 用得到的列,range 结尾不带行号(给大数 A1:H999999 反而报超限,飞书按请求范围算体积)。少一列就抛,不静默降级成缺字段 |
| 那张表长到 84,952 行 × 9 列,只读一类也超 10MB —— 全库最大的一块(8 万多根)整张读不到 | 按行分段读再拼,段太大就当场砍半重来(分段读,lib/direct.mjs)。没有「正确的段长」这回事:飞书量字节、我们数格子,一张带备注列的表在 17.5 万格就超,而短字段的表到 76 万格才超。所以 每段格子 只是起点猜测,超了自己按 log₂ 收敛到下限 200 行。任一段失败整张作废 —— 半张表的数看着正常、只是合计小一截,没有一处会红。收敛到的段长记进结构缓存,之后不再打那趟注定失败的整读 |
| 表头是富文本:山西光模块的「品牌(必填)」是白色「品牌」+ 红色「(必填)」两段,返回结构体 | 取文本() 提取所有 text 拼起来。这是从 build_stock.py 的 cell() 抄的语义,tests/direct.test.mjs 里有一条判据拿同一批样本比对两边结果,防漂移 |
临港联通那张被网卡/硬盘/光模块三个方案共用,includeRules 没筛干净,1,333 根被算两次 |
按方案包顺序剔重,剔除数量报进口径块 —— 它涨了就说明筛选规则该修 |
默认读回来的是公式文本(台账「物料键」列 340 行全是 IF($A2="",...)) |
一律带 valueRenderOption=UnformattedValue。不带的话哪天数量列用了公式,Number("=SUM(...)") || 0 会静默算成 0 根,而守恒检查照样绿 |
lark-cli 报错有两条路,原来只认了一条(2026-08-15 查清):90221 走退出码 1 + stderr,正则抠得出 code;91402 走退出码 0 + stdout,code 藏在 error.code 而调用方读的是 j.code,拿到 undefined |
认错误码() 把两条归一:失败时 code 一定是字符串、一定在顶层。这不是报错难看的问题 —— String(j.code)==='90221' 对后一条路永远为假,「表长大了改走分段读」会退化成「这张表读不到」。排查读表慢时见过一次 code=undefined,当时归因成网络超时,归错了。run-tests.sh 有一条结构检查:lib/ 里按错误码分支的文件必须用 认错误码 |
位置粗到库房,货位不管
源表里的 403库房 / CK2-TEMP / 二楼小仓库 是货位,74 种,一律不进物料键 ——
位置取 source 的 region(闵行 / 临港9号楼 / 移动7号楼 / 移动45号楼 / 七宝 / 山西 / 临港联通)。货位是仓管的活。
走原生 values 接口,不用封装的 +csv-get:后者超 10MB 会悄悄只给你一部分(ok:true、数据也是好数据,只有一个 has_more:true),我因此在一个 9.4% 的样本上算过百分比还以为覆盖了全表。原生接口超限直接报 90221,静默失败变显式失败。
台账本身是人每周从油猴导出件粘贴进来的,不是实时的。口径块里的「读取时间」是这份数字的时刻;飞书的版本号留在 meta.revision 里,代码用它判断变没变,不往口径块塞——对人是一串没信息量的数字。
采购在途:已经许出去、但货还没到(2026-08-17)
占用表里出现了第四种闭环状态「采购在途」。做法是:人在物料表手工加一条, 品牌和库房留空(那两段要到货才定),占用挂在它上面。实测那条:
光模块||QSFPDD-400G-DR4 | 在库 0 · 占用 640 · 预计到货 2026-08-31
备注 智设合【2026】年325-004 · 工单 29640
危险的不是账面上那个 −640,是到货那一刻。 货落在源表产出的四段全的真键上
(光模块|海光芯创|QSFPDD-400G-DR4-SM1310|闵行),那个键的占用是 0,
于是同一批 640 根在两个地方各算了一次:空键说「许出去了」,真键说「可用」。
人拿真键那个数承诺就是超发。台账里 400G DR4 现成的真键有 11 个、合计 607 根,
和这笔采购一个量级。
挡它的是一条反方向的检查
挂可用量 原来只往一个方向查:源表有、台账没有(该导没导 / 不进台账的)。
反方向「台账有占用、源表却没有这个键」完全看不见 —— 那正是在途落进去的地方。
现在补上 没挂上的占用,出现在三处:
| 在哪 | 为什么 |
|---|---|
查库存 |
排在数据前面,不进口径块也不进调试档 —— 它不是「这个数怎么算的」,是「照这个数发货会超发」 |
找替代 |
这里更致命:结论会直接变成「不用采购了」 |
./周更.mjs |
每周该看的就是「在途到了没有」,节奏本来就是周级的 |
不按资产类型或型号过滤,全都报。 这类键极少(实测全仓 1 个),而漏报的代价是超发; 过滤逻辑本身会变成一个失效时是绿的点 —— 过滤错了就静默不报。几行噪音换零漏报。 和「筛选一条都没命中」同脾气:一直报到有人处理为止。
日期跟着每一条走。 人拿到「有 640 根在途」之后的动作完全由日期决定 ——
等它到,还是另想办法。没填就明说「没填预计到货,问一下什么时候能到」,
不编一个出来:编了的后果是有人照着一个不存在的到货日排工期。
预计到货 在 读占用 里是软要求(缺列只是少个日期),而 物料键 / 占用中
缺了就算不出可用量,是硬的 —— 两者不共用一个 throw。
人要做的只有一个动作
到货后,把占用记录那行的「关联物料」改指到真键。 改完空行变成 在库 0 / 占用 0, 警报自动消失,那 640 根在真键上被正确扣掉。
闭环状态 那个下拉框 MCP 一个字都不看 —— 它在占用记录表上,而 MCP 读的是物料表。
改它是给飞书 占用时长 那一列用的(🟠等货中 / 🔴等货超期),为人自己看。
两处没有判据、要知道:
- 挪错了抓不出来。 只检查「有没有挪」,不检查「挪得对不对」。挪到
QSFP112而不是QSFPDD、或者挪到错的库房,一声不吭 —— 因为挪完之后那个键在源表里确实有货。 - 分批或分散到货得拆行。 一条占用记录只能指一个物料键。
讨论过、没做的三条
| 为什么没做 | |
|---|---|
拿 闭环状态 当开关(还是「采购在途」就不扣、改掉了就从同款货合计里扣) |
要多读一张表 + 约 50 行;人拍了先不做 |
| 在途单开一张表 | 承诺一旦分两张表存,就没有一个地方能回答「这张工单一共答应了什么」 |
扩占用记录表(加型号/单号/ETA 三列,关联物料 降级成选填) |
形式上最规范,但 剩余占用 / 同工单同物料几行 / 数据检查 四个公式都要分叉,而飞书公式没法写测试 —— 这是整个项目里最验不动的一层 |
| 周更跳过「备注或预计到货非空」的行 | 量过了:399 行里符合的 1 行,其中在库非 0 的 0 行 —— 这条规则今天是空的。它要成立必须靠人工填在库,而那会给 在库数量 这唯一单所有者的列引入第二个所有者 |
台账:每周导一次,只增不减
台账是「配件占用管理表」的物料表(多维表格)。MCP 读源表算在库,台账那侧算占用,
两边按物料键对上,可用量 = 实时在库 − 台账占用中。
台账只收走领用流程的配件(台账范围,定义在 lib/ledger.mjs):光模块 / 硬盘 / 网卡 / 内存。
随整机走、不按物料键单独领的东西不收 —— 进了台账只会在下拉框里多几个永远没人选的键。
导入时先过滤再校验,日志会打「挡掉 N 个不在台账范围内的物料键」。
这个集合和方案包(plans.json)现在恰好一样宽,但两者管的是两件事:方案包管
「MCP 读哪些源表」,台账范围 管「哪些资产该进占用台账」。方案包加一类资产时要单独
判断它进不进台账,别默认跟着走 —— 否则新加的那类会静默进台账,多出一批没人选的键。
「台账里没这个键」因此有两种,挂可用量 分开计数、口径块分开报。合成一个数的话,
只要存在任何一类「本来就不该进」的资产,它就永远大于 0,人看几周就习惯性忽略,
真有新货没导时也看不出来,还会照着「跑一次导台账」这条修不好的建议白跑:
| 信号 | 含义 | 怎么办 |
|---|---|---|
还没进台账的键 |
范围内、台账里却没有 | 跑 ./导台账.mjs |
不走台账领用的键 |
范围外、随整机走的 | 什么都不用做,它的「可用量」就是在库数 |
./导台账.mjs --dry # 先看一遍:新增几条、更新几条、置 0 几条
./导台账.mjs # 真写
./周更.mjs # 每周四:重读 → 和上周基线比 → 出报告 → 品牌待补清单 → 存基线 → 写台账
--dry 里最该看的是「置 0 的」,而且要逐条问「这批货是没了,还是换了个键」。
--dry 只报「原在库 → 0」,不告诉你那一格现在还有没有别的货 —— 后者才分得出
「真出库」和「改名/重分类」。判据是拿键的「资产类型|品牌|库房」去这周的实时数据里查:
那一格还有别的型号 = 大概率改名,那一格空了 = 这一格真的清了。
2026-08-14 那次实测:24 条置 0 里 19 条落在「硬盘 · 临港联通」,型号却是
QSFP112-400G-DR4-SM1310 这种光模块写法 —— 加起来正好 926 根,
和那天修的「临港联通光模块被读成硬盘」是同一批。这种情况置 0 是对的,
它正是这次修复要清掉的错键。
写入规则一条不改,来自台账那侧的设计说明 §3.3:按物料键 upsert,把「这次没出现在
导入里的物料」的在库数量置 0,而不是删记录。置 0 之后记录还在,占用记录的键仍然匹配
得上,级别自动变「待采购」——库里没货还欠着人,正是想要的语义。代码里永远不调
record-delete:删了占用记录就悬空,而物料那一侧是完全静默的(可用量会悄悄涨回去、
级别还写着「充足」、数据检查是空的)。
四道闸,任何一道不过就整批不写(不是「跳过坏的写好的」):
| 闸 | 挡什么 |
|---|---|
| 有源表没读到 | 那个库房会被当成「清零」全部置 0,而货都在 |
| 键不合法(四段有空/含竖线) | 空键会「吸走所有空键的占用」,且完全静默 |
| 键归属对不上多个老键 | 这周把两个老键的写法合成了一组,机器不猜留哪个 |
| 写完读回核对 | 「API 返回 ok 但值是空的」是这张表最贵的错误 |
键一旦发放就锁死(lib/keyreg.mjs)
占用记录存的是键的字符串,而键的第三段是归一「选」出来的标准写法 —— 实测 26 个合并组
里有 9 组是靠根数决出来的(CX6-25G双口(287) vs CX6-25G*2(158),一次出库就翻盘)。
翻盘后老键归零、新键冒出来,占用记录还挂在老键上,那笔占用永远结不清。
锁法不靠槽位序列化(网卡根本解析不出槽位),靠 normalize 现成的 原始 字段:
新算出的键只要和某个已发放的键共享任何一个原始写法,就判定是同一款货,沿用老键。
判据是「共享写法」不是「键字符串相同」,标准写法怎么翻都不影响身份。沿用老键这件事会
报出来(台账里显示的还是老写法,人会觉得不对)。注册表在
~/.cache/inventory-mcp/键注册表.json,写完台账才更新 —— 台账写失败的话键不该算发出去了。
踩过的坑(都实测过):+record-list 默认分页 100、最大 200,不翻页的话台账超过 100 条
之后那些会被当成「不存在」重复新增,而 API 一路返回 ok;+record-delete 的 --record-id
是可重复参数,拿空格拼一串传会报「Record id must start with rec」而 ID 其实合法;
返回是列式的(data.data 二维数组 + data.fields 列名),不是每条一个 fields 对象。
品牌怎么补
源表品牌列空着的有三种补法,可信度不同,所以分开放、分开报:
| 来源 | 粒度 | 放在哪 | 口径块里报成 |
|---|---|---|---|
brandAliases |
写法映射(Samsung→三星) | plans.json(油猴那边也读它) |
归一改动 |
| CMDB 导出 | 逐根 SN 查证 | ~/.cache/inventory-mcp/sn-品牌.tsv |
按SN补的品牌 |
| 人工整批判断 | 库房 + 资产类型 | lib/ledger.mjs 的 品牌补充 |
品牌是补的 |
补出来的值还要再过一遍 brandAliases —— CMDB 写 CLT、库存写 海光芯创,
不过一遍的话台账里就是同一家两个名字、两套键。归一完还有一条会抛的检查:
结果里但凡还留着「对照表里有标准写法」的品牌,直接报错不返回。
补不上的进「品牌待补」,每周四出一份 SN 清单(~/.cache/inventory-mcp/周报/品牌待补-*.csv)
拿去 CMDB 查。给 SN 而不是型号,因为 CMDB 里按 SN 才查得到一根货的品牌。
查的时候品牌传错了:工具知道答案,就别只回一个 0
查库存 的品牌是精确匹配(kw(r.brand) === kw(条件.品牌))—— 数据侧读表时已经用
brandAliases 归一过,所以查询侧要求传标准名。但客户嘴里的牌子是英文、是简称、是打了一半,
模型没翻的时候拿到的是一个不带任何线索的 0,和「这批货真的没有」长得一模一样,
而后者会被原样报给人。零命中那套补救(放宽哪一项、差得最少的几个)只在给了型号或槽位时才算,
{品牌:'Samsung'} 这种查询压根进不去。
现在命中 0 且给了品牌时,返回体里多一条「品牌这一项」,四种情形分开答
(品牌怎么救,lib/ledger.mjs):
| 传进来的 | 回什么 |
|---|---|
三星(名字本来就对) |
不是牌子的问题,是其余条件的问题 —— 这条最容易被误读成「这个牌子没货」 |
Samsung(别名) |
标准名是「三星」(全库 N 根),换成它重查一次 |
海光(打了一半) |
名单里没有,但有「海光芯创」—— 候选来自库里真有的牌子,不是拼出来的 |
浪潮(不认识) |
名单里没有,库里有货的是这几个。0 根的不列 —— 列了等于把模型往一个空结果上引 |
四种情形的顺序不能反:brandAliases 允许自映射,标准名会出现在自己的别名列表里,
先判别名的话,传标准名会被答成「你填的是别名」,把人往错方向带。tests/ledger.test.mjs ㉙ 守这条。
判据只有一份,家在 lib/slots.mjs
判「两个写法是不是同一款货」的逻辑在 lib/slots.mjs,lib/ledger.mjs 直接 import。
2026-08-16 之前它不在这儿 —— 住在 userscripts/feishu-warehouse-composer/slots.mjs,
MCP 运行时按 export function 建槽位( 这个字符串标记切一段出来 new Function 跑。
那一整套(环境变量覆盖路径、切片标记、切不出来就抛)都是为了「判据只有一份、家在油猴那边」
这个约束才存在的。
约束没了,所以搬家:那个油猴脚本(飞书仓库组合器)不再更新 —— 飞书那侧 2026-08-11 起
改走 lark-cli 官方接口,逆向那条路退休了。依赖一个没人维护的路径,比多一份拷贝更危险:
拷贝会漂,但漂了有判据能抓;而路径哪天不在了,报出来的是「切不出建槽位」,
没人看得懂那是什么意思。油猴那边那份留着给它自己用,两边不再有同步关系 ——
它不更新了,也就不会漂。
判同款漂了不会报错,只表现为「库存翻倍」或「同一款货查不到」。实测过:
QSFP28-100G-SR4(多模)和 QSFP28-100G-LR4(单模),缺了「标准推光纤类型和波长」
那一步就判成同款,发过去插上不亮。所以它值得有一份自己的家,而不是寄人篱下。
run-tests.sh 那条检查跟着反了方向 —— 原来查「必须去读油猴那份」,现在查三件事:
判据文件在且是本仓库的、lib/ledger.mjs import 的是它、lib/slots.mjs 之外没有第二份
槽位词表字面量。查的是依赖机制不是「提到」:第一版写成 grep 路径字符串,
把解释这段历史的注释也报红了 —— 判据写太宽和写太窄一样坏。
品牌对照表还在外面,家是 plans.json 的 brandAliases(油猴「品牌对照表」界面改的
那份,INVENTORY_PLANS 可覆盖)。这边只读、不留副本,读不到就抛;同一个写法在两个方案里
映射到不同品牌也直接抛,不静默取一个。它和槽位判据的区别是:那份还有人在用界面改,
所以它该留在被改的地方,而不是搬进来变成第二份。
跑测试
./run-tests.sh # 改的过程中跑:23 秒,305 条判据 + 61 个消融,不打网络
./run-tests.sh 全 # 收尾 / 提交前跑一次:89~190 秒(缓存热时 89),打网络的五条链并行
./新旧.sh # 跑着的那个 MCP 是不是最新代码 —— 它是 WorkBuddy 启动时拉起的单例,新开对话不换进程
./自检.mjs # 这套东西能不能跑起来:lark-cli / 登录态 / 方案包 / 真读源表 / 台账 / 三件静态检查
./自检.mjs --快 # 同上,跳过真读源表那步(不打网络)
分两档是量出来的:打网络的 5 个测试加它们的消融占全套 635 秒里的 630 秒, 而 15 个不打网络的测试加 14 组消融一共 5 秒。改一行代码等十分钟才知道有没有改坏, 这个循环长到没人会在改的过程中跑它 —— 于是它只在收尾跑一次,而那正是最晚发现问题的时刻。
全 那档五条链并行(2026-08-17 实测 624 秒 → 195 秒,判据和消融一条不少)。
五个正跑单独测过:串行 275 秒、并行 92 秒,一个都没被频控挂 —— 当天做的退避重试兜住了突发。
写路径 那条链必须串着,而且不许和自己并行:它往生产 base 建同名前缀的表、
finally 按前缀清理,两个进程同时跑会互删对方的表,而失败方式是「表突然不见了」,
看起来像接口出错。它整条链(正跑 + 消融)在一个子进程里顺序跑,跟别的链并行。
输出按固定顺序印、不按完成顺序 —— 两次跑的输出要能直接 diff。 每条链的秒数也收回来(并行之后它们从秒表里消失过一次,而「量不到就没法再优化」 正是这一档存在的理由:624→195 就是照着秒表找出来的)。
默认那档末尾会明说没验哪几项、它们各守什么。退出码照旧 0(它确实通过了它跑的那些), 但只跑了一半不许印得像全跑了:「判不了」和「通过」合并成一个无差别的绿, 是这套东西最不能出的那种错。
shell 脚本交给 shellcheck,不自己写正则(brew install shellcheck,没装就报红——静默跳过等于永远通过)。它认得出这个仓库真栽过的那种:计时="" → SC2276 This is interpreted as a command name containing '=',bash 会把它当命令执行、报 command not found 然后接着往下跑,变量恒空而脚本照常绿着跑完(bash -n 查不出来,语法合法)。2026-08-17 一天栽了五次。
接的时候防住第二件事:解析中途停了不许算数。 中文函数名(秒() {)bash 认、shellcheck 不认,它在那一行报 SC1088 停止解析、照样退非 0——看起来在干活,实际 260 行只扫了 28 行,而前面那几条 appears unused 全是假的(它没看到用的地方)。所以 run-tests.sh 里的函数名是 sec / run_one / teeth / chain 而不是中文:中文函数名合法,但它把这个仓库唯一的 shell linter 挡在门外。
判据编号是跑出来的(ok #37 …),不是手编的圈字符 —— ㊱–㊿ 在 90 条判据那儿早用完了,手编到后来必然重号,FAIL ㊹ 分不清是哪一条。加判据不用维护编号。
消融个数是数出来的,不是写在 run-tests.sh 里的(tests/消融.mjs)。每个测试文件用
认消融(N) 声明自己有几个,套件从 1 一直试到测试回退出码 2 为止。
退出码四档,tests/消融.mjs 的文件头是唯一一份定义,缺哪一档就分不出对应的两种情况:
| 码 | 什么意思 | 缺了它会把什么误当成什么 |
|---|---|---|
| 0 | 全绿(消融时 = 这个消融是恒绿的,必须报) | — |
| 1 | 有判据挂了 | — |
| 2 | 没有这个消融号,停 | 「压根没这个消融」当成「消融有效」 |
| 3 | 挂了,但有一条是撞飞书频控 → 这轮没验成 | 「网络那分钟太忙」当成「代码坏了」;消融那圈里更糟,一个根本没被执行到的消融被数成一颗牙 |
3 不是通过。 它让整套退非 0、要求重跑一遍 —— 所以就算某轮里混了真失败被一起标成 ⏸, 干净的那一遍照样会把它印红,最坏是晚一个来回看到,不是看不到。
这条是踩出来的:分段读 加了第 3 个消融、run-tests.sh 里还写着 for m in 1 2,
那个消融从来没跑过,而汇总栏照印「✓ 2/2 消融都变红」 —— 一条新判据加进去了、
从没被验证过会红,而整套测试全绿。顺带堵上另一个:决定.test.mjs 原来对认不出的消融号
什么都不做(if…else if 没有 else),ABLATE=3 既没跑正常路径也没跑消融路径,
四条判据一起挂,从退出码看和「消融 3 起作用了」一模一样。
| 测试 | 打不打网络 | 守什么 |
|---|---|---|
tests/ledger.test.mjs |
不打 | 归一 + 坏件排除 + 字面规范化 + 缺表降级 + 品牌补充 + 型号写法不像这一类(第五道检查)+ 品牌传错时说一句。33 条判据 |
tests/direct.test.mjs |
不打 | 直读那条路。60 条判据:URL 解析、方案包认不出 URL 要抛、位置取「地区」不取「库房」、一条 SN 一根、跨方案剔重、聚合不守恒要抛、结构缓存校验、缓存段长只许比当前策略小、lark-cli 两条报错路归一、SN 逐单对比、筛选命中率、撞频控从认出来到套件退出码整条链(含「不是限流的失败一次都不重试」和「\b429\b 不匹配 rec429xx」) |
tests/substitute.test.mjs |
不打 | 找替代 + 放宽代价。59 条判据:死路上摆「你是不是想找」(㊺㊻㊼,2026-08-24 加)—— 客户在厂商料号后面缀项目名(MZQL23T8HCLS-00B7C-JD项目)时,槽位解析不出、字面也对不上,这条路原来一个线索都不给,而答案就在库里。判据是指纹包含(库里那条的字面指纹整个包在你给的里面,或反过来),召回样本 23 条死路救回 21 条、每次候选中位 1 个。不用编辑距离:阈值 ≤4 才救得全,而 4 恰好是 JD项目 的指纹长度 —— 那是对测试自己那条变形规则的过拟合。两边都要挡空指纹(fq.includes('') 恒真,只挡一边的话型号写成 / 的脏行会出现在每一次候选里,实测一次摆 61 条)。摆出来的永远不叫「命中」、也不进召回率 —— 人挑一个再查一遍才算找回来。「精确匹配」那一档里没约束的槽位混着什么,要点名(㊸㊹,2026-08-24 加)—— 客户写「400G DR4」不提封装时,QSFP112 / OSFP-RHS / QSFP-DD 三种封装会一起进精确档,而它们是物理上不同的笼子,整档报成「和你要的一模一样」会让人发出插不进去的货。反过来也要报:没约束那项其实只有一个值时(库里 246 个真实型号里 27 个是这样)要明说「这次确实是一模一样」,否则人为一个已经收窄到底的结果白重查一遍。消融 12 把分布拿掉,㊸㊹ 两条都必须红;厂商料号(一个核心槽位都解析不出来)三档全空 + 说清「没法判断什么算它的替代」(2026-08-23 修的:原来它们把整类列进「精确匹配」,也就是一句「这些和你要的一模一样」,实测 30 种网卡里 20 种被这么列 —— 比查库存那个洞更糟,那边只是数字大,这边是一句会被照着发货的断言),同时要说一句「库里就有这个」免得人以为没货;其余:四档的边界、≈ 那一档要带依据、只差一项的要点名其余哪几项相同、不许写成「能替代」、相近档分层(含按代价拆层)、平铺列表和分层同序、网卡「决定不做」和「还没定」不许混、占用读不到时不拿在库冒充;放宽按代价排不按根数排、自动放宽只挑「能放」、只有「不能放」的能捞到货时才准说「没有」、每个核心槽位都定过代价 |
tests/weekly.test.mjs |
不打 | 周更:键校验、周差异三类、待补清单和快照对齐、改名解释(键级噪音摘不掉就变红)、「键归零」和「台账会悬空」分开(拿原始写法的消失去断言归一后的键会悬空,每周误报一次)。20 条判据 |
tests/源表.test.mjs |
不打 | 看源表。13 条判据:列名不一样的必须点名、缺字段的表要点名、空映射当「没有」不当「叫空」、筛选规则和表头行原样给、名单截了要说还剩几张 |
tests/scope.test.mjs |
不打 | 口径块瘦身。7 条判据:调试字段默认不发 / INVENTORY_VERBOSE=1 全回来 / 影响回答对错的一条都不许瘦掉 / 留下的值和详细档一致。探针是 tests/scope-probe.mjs(环境变量是模块加载时读的,只能起子进程验) |
tests/sn.test.mjs |
不打 | 查SN。19 条判据:多种原始写法收回同一个键、闵行库房 要折成 闵行、认不出的 SN 不许静默丢、在库和有SN分开、一写法对两键要抛、CSV 带 BOM / 逗号转义 / 文件名去重、写盘读回一致 |
tests/资源.test.mjs |
不打 | 资源 / 提示模板 / 参数补全,外加 resources/list 每类只列最近几个、截掉的仍然够得着。17 条判据 |
tests/通知.test.mjs |
不打 | list_changed 通知。6 条判据 |
tests/召回.test.mjs |
不打 | 召回率回归。189 个真型号 × 9 种客户写法扰动,跑的是 tests/fixtures/召回样本.json(不打网络)。原样必须 100%、整体 ≥95%、不许低于上次基线。5 条判据。新增的第 4 条守「问句不许混进召回率」(2026-08-24):带项目尾巴 掉的那 23 条现在会摆「你是不是想找」候选(指纹包含,救出 21 条),它单独进 问句 那一列,命中数必须还是 166 —— 混进去的话召回从 88% 跳到 99%,而一个人都没多拿到一根货。这条边界只能自己守:召回基线是 >= 比较,指标虚高它一声不吭。消融 2 就是把问句算成命中那种改法。命中分「走槽位」和「走字面」两列:库里 23 个厂商料号(MZQL23T8HCLS-00B7C 这类)一个核心槽位都解析不出来,走字面指纹那条,它照样算「查回了自己」,但不许靠「放宽一项」凑数。2026-08-23 带项目尾巴 的基线从 100 降到 88 —— 唯一一次降基线,掉的 23 条逐条归因过、全是那批料号原来靠「命中整类」白拿的分,理由写在测试文件的基线注释里 |
tests/决定.test.mjs |
不打 | 人拍过的决定。17 条判据:依据/谁拍的不许空、跑两遍状态一样、方向和写法都归一之后判重、改口留上一版、拍「不能顶」的不许消失、坏文件要抛但不许拖垮查询 |
tests/凑单.test.mjs |
不打 | 收尾那一行。10 条判据:一处不写数量、多处各自带数量、用的处数是最少的、凑不够不写「满足」也不逐处铺开、没给「要几根」不许下结论、按可用量凑、次序确定 |
tests/发通知.test.mjs |
不打 | 匹配完拼好通知文本、按收件人分段返回(不真发)。9 条判据:分满足/缺货/待定(命中键区分「匹配上但不够」和「没对上库存」、带机房)、资管段+采购段(缺货/待定两段、只有待定时不出缺货段)、归一型号和 normalize 字面指纹同口径(分隔符变体不漏配) |
tests/日更工具.test.mjs |
不打 | 日更 MCP 工具的两步闸。3 条判据:第②步不写不执行、重放已消费票/伪造票都票据无效(①和真写要读写飞书台账,离线测不了,靠 日更.test.mjs 的算红 + 真站手验) |
tests/周更工具.test.mjs |
不打 | 周更 MCP 工具的两步闸 + SN 快照够不够新。10 条判据:第②步不写不执行、重放/伪造票都票据无效;以及 快照判据——原来这儿是 setTimeout(2000) 干等落盘(写 10MB 实测几十毫秒,白等约 1.95 秒),而万一慢于 2 秒或落盘那条路把异常吞了(.catch(() => {})),读到的是上一次的快照:周环比照样算出一个数,「和上周比」悄悄变成「和上上周比」。最贵的是存基线——那时候 这周行 是空的,存下去下周一比全仓二十几万根全算「新增」,一路全绿。现在改成 await 真正的落盘 promise,没滚就停掉周环比/待补/存基线三件事并报红(①和真写要读写飞书台账+基线,离线测不了,靠 weekly.test.mjs 的差异算法 + 真站手验) |
tests/导出.test.mjs |
不打 | CSV 转义 / BOM / 桌面和缓存两个去处分开 / 旧文件按时间戳淘汰(按文件名排会让上周的 分布- 挤掉今天的 SN-)。31 条判据。自动落盘的目录里同一组只留最新一份(2026-08-22;实测 21 个文件里 16 个是同一组 分布-全部,只是时间戳不同);桌面豁免——桌面那份是人明确要的,同一组三份不许自己消失,只受「留几份」总量约束。这条默认值抽成 按组收吗(目录) 单独可验,因为它错了会删人桌面上的东西。外加无主文件 7 条(㉑~㉗):清理器只碰对得上格式的名字,副作用是别的文件永远清不掉、而且没有任何一处会提起它(实测残留 导出/probe.csv)。报出来但一个都不删;桌面一律返回空 —— 桌面上全是人自己的东西,在那儿报无主离「顺手删掉」只差一步(这条拿掉闸消融验过必红)。 |
tests/不膨胀.test.mjs |
不打 | 静态扫每个 mkdirSync 的目标,逐个对上清理函数登记表 —— 新开一个目录忘了配清理就红。外加:起 server 子进程的测试必须把「会改别人答案的状态」指到临时目录(SN 快照是 看变动 的基线、周报和导出会被 列资源 扫;不隔离的话生产状态被搅乱,而别的测试读它就变成「单跑绿、全档偶尔红」)。8 条判据 |
| 各自的消融 | 三个打(分段读 / 增量 / 写路径) | 每个拿掉一处承重逻辑,都必须变红,否则断言是恒绿的。个数这里不写 —— 写死过一次就漂过一次,跑一遍看汇总栏那行 ✓ N/N 才是准的 |
tests/分段读.test.mjs |
打 | 超 10 MB 的表分段读 + 撞了 90221 自适应砍半。8 条判据 |
tests/增量.test.mjs |
打 | 增量重读。6 条判据,只有一条要紧:增量结果必须和全量逐个物料键一模一样(不能拿「守恒过了」当判据 —— 复用了一本其实变了的文档,总数少一截而每步守恒照样绿)。造场景靠 INVENTORY_FAKE_CHANGED,指到临时目录跑、不碰真缓存 |
tests/自检.test.mjs |
不打 | ./自检.mjs 自己的回归 + 方案包路径怎么找 + 缓存里的无主文件。10 条判据:正常时退 0、缺方案包要红、红的那条后面跟着「怎么办」(只报「坏了」的检查等于没有)、一项坏了后面照样跑完、lark-cli 找不到时说清它跟 WorkBuddy 走;外加路径三条:按顺序找、别处不再自己写一份默认路径(原来四个文件各写一份)、INVENTORY_PLANS 指到不存在的文件时不许静默降级(人以为在用 A 实际在用 B,而两份方案包的差别不报错、只表现为数字对不上);⑨⑩ 缓存根目录里的无主文件要报、退非 0,而 缓存常住 名单上的 rows.json 不许被算进去(报了它下一步就是被人顺手删掉全量行缓存)。判据①②跑之前把缓存指到临时目录(INVENTORY_CACHE_DIR)—— 不指的话「一切正常时全打勾」的红绿取决于跑测试的人机器上攒了什么,跟代码对不对没关系 |
tests/文档没漂.test.mjs |
不打 | README 里写死的数字有没有漂。3 条判据:每个不打网络的测试表里都有一行、写的判据数和跑出来的一致、仓库根每个入口脚本文档里都提到了。加它是因为一次审计发现两个新测试压根没进表格 —— 这类漂移不报错,只会让文档变成一份看着像真的、对不上的东西 |
tests/分发.test.mjs |
不打 | tools/call 那条分发路径,0 秒。8 条判据:分发通不通、返回体没被改坏、分发处的钩子真的产生了副作用(只验返回值的话钩子静默抛异常看不出来)、改过名的老参数当场顶回去、没有的工具名报错、ping 回空 result、返回体里不许出现台账那条写死的 wiki token。在它之前这一档里没有任何测试执行过 tools/call |
tests/问答日志.test.mjs |
不打 | 记下模型实际问了什么。9 条判据:参数原样记不补默认值、失败也记(命中 0 和「压根跑不通」是两类问题)、不许拖慢查询、按月切只留最近几个月、关得掉 |
tests/keyreg.test.mjs |
不打 | 键锁死 + 台账范围 + 占用的两个方向 + 源表整批改写法。27 条判据:标准写法翻盘要沿用老键、跨库房不沿用、合成一组要报冲突;「该导没导」和「不走台账领用」分开计数(源表有、台账没有);「台账有占用、源表没这个键」也要报(在途落进去的地方,漏了就是超发),日期要跟着每一条走、没填不许编;找前身 只对「跑完在库归 0 的同类型同品牌同库房老键」出候选、槽位解析不出的不出候选,认改名 只许撤这次新发的键、并完之后 对键 真的不再发新键 |
tests/规格新鲜度.test.mjs |
不打 | 「按 SN 校准型号」那份缓存旧了要能看出来(lib/规格新鲜度.mjs)。15 条判据。背景:源表的型号,硬盘写粗略容量(3.84T)、内存写厂商料号(128GB 2Rx4 PC5-5600B-RA0-1010-XT),人读不懂也匹配不准;CMDB 按 SN 逐根记着精确规格(3.84T NVMe U.2 Gen4、128G DDR5 5600),所以查询时按 SN 换成 CMDB 那份(direct.mjs 的 sn规格())。这份缓存离线生成、不会自己更新,新到的货和 CMDB 改过的规格都不在里面,而变旧的表现是「那些货的型号还是老样子」——不报错、不变红。守四件:① 只算硬盘+内存(光模块/网卡源表本来就写人读的规格串,一根都不许算进「该覆盖」);② 缓存不存在时报 0 且不抛(日更不能因为这个中止),并说清是「从来没生成过」;③ 新鲜的不报警(天天报人就当噪音了),7 天才报;④ 报警里必须说清「这事不报错」和具体多少天 —— 不说人不会当回事。距今天数夹到 ≥0(刚写完的文件 mtime 可能比 Date.now() 晚几毫秒,Math.floor 会算出 -1) |
tests/查库存.test.mjs |
不打 | 主路(lib/查库存.mjs,九个工具里被叫得最多的那个)。2026-08-22 从 server.mjs 搬出来时只验到「行为没变」,一条自己的判据都没有 —— 在那之前它只在「起真服务器 + 打真网」那一档被间接跑到,改一行要等几分钟。35 条判据,依赖全从工厂注进去(取数/相关缺表/取枚举/取源表数),7 处消融验过必红。挑判据的口子是错了会不会报错,这个模块的错几乎全是静默的:①H3C 捞走 H3C-新华三 会多算,不报错);⑨⑩ 完全读不到的库房根数是 null 不进合计(当 0 加进去 = 把「不知道」算成「没有」);⑪⑫ 明细梯队在前量在后(第一行就是人当成答案的那一行,而二梯队要跨库房协调);⑬{},而空槽位集对每一行都逐项相等 —— 问 中文型号 回的是整个资产类型的全部库存,还附一句「按槽位匹配」。方向是最坏的那个(多报:少报人会追问,多报人直接许出去了)。修法是「解析出空集 = 没解析出来」,消融 5 重演旧行为必红;⑰ 槽位填错当场抛且一次表都没读;⑱⑲ 枚举和源表张数每次现取(热重载后的 let,按值快照会让新资产类型永远认不出);⑰~㉒ 匹配有多严要说清(详见「槽位这条路的两个洞」那节):全解出来的说「这是精确匹配」,只解出一部分的明说「这不是精确匹配」并点名哪几项没参与比较,只给非核心槽位(lanes/media 在词表里、不在 core 里)当没约束处理、不许整类全回,厂商料号只按字面(比指纹)回它自己那条并标出来;㉖㉗ 参数回显是解析后的槽位串且能原样贴回去(多轮追问记漏一个槽位多查一大截、多带一个少一大截,两种都不报错);㉒ 缺表警告排在数据块前面;㉓ 明细几行:0 真的一行不给;㉝㉞㉟ 明细按库房聚拢(同一库房的行连成一段——排序键少了库房那一层,同梯队内两个库房会按根数交错,而模型要 group by 库房才写得出「一个库房一行」,它在脑子里合并的时候梯队顺序就散了,人看到二梯队冒在最前面。样本是对抗性的:二梯队那个库房的量是全场最大) |
tests/答法.test.mjs |
不打 | 展示层(lib/答法.mjs:答法/答法差/口径差/明细行/落地行数)。这一层错了全是静默的 —— 回包照常有、数字照常对,只是模型看到的指令变了或该转达的警告没转达。32 条判据,时间靠注入不靠 sleep。守四件:① 格式指令的形状(永远要求原样带 ⚠、禁流水账);② 去重不许吃掉警告 —— ⚠/读取时间/源表链接 值没变也必发(一个警告只报一次然后消失,读起来就像已经解决了),去重掉的要明说「其余同前」,警告消失了要主动说「已解除」;③ 会话边界(2026-08-22 修的真 bug:原来去重状态永不重置,而 MCP server 是长驻进程、实测跑过 23 小时 —— 同事 A 早上问过之后,同事 B 下午拿到的是「其余同前」,而 B 从没看过那个「前」。现在按「离上次调用 10 分钟」切会话);④ 明细行的分支(物料键只在落文件时带、占用字段不编、「随整机走不该在台账里」和「漏录了」不许混成一个)。11 处消融验过必红 |
tests/工具契约.test.mjs |
不打 | 给模型看的那份契约(名字/说明/参数表/annotations)的快照 + 不变量。回归里唯一真起一次服务器的地方 —— 2026-08-22 把工具表搬进 lib/工具表.mjs 时 node --check 过了、全套绿了,服务器却起不来(漏搬两个常量、运行时才 ReferenceError),就是因为之前没有一条会执行到「起得来吗」。7 条判据:真起服务器并报拿到几个工具(0 或偏小=没检查)、每个都有名字、说明非空(说明是模型唯一的使用手册)、参数表是 object schema、无重名、required 点名的键 properties 里真有、整份契约哈希对基线。契约变了不一定是坏事,但必须人看过 diff 之后 node tests/工具契约.test.mjs 祝福,不许悄悄漂。6 处消融验过必红(改一个字的说明 / 改 annotations.title / 删一个工具 / 服务器起不来 / 说明变空 / required 点空头承诺) |
tests/依赖方向.test.mjs |
不打 | 依赖方向交给 linter 强制(.dependency-cruiser.mjs),不靠纪律。7 条判据:干净必绿且要报它看见了几个模块几条依赖(37/129;0 或偏小 = 没扫到、不是没问题,tach 就这么打过假勾);四条规则各造一个违规样本必红——禁循环(有一边会拿到半初始化的模块)、lib/ 不许反向 import 入口(否则整张工具表被拖进每个用它的地方)、不许 import 伸出仓库(换机器断在运行时)、禁孤儿;样本清掉必须重新绿 |
tests/死代码.test.mjs |
不打 | 死代码棘轮,knip 走真的 import 图(grep 数不准:中文标识符和注释里提到的名字分不开)。5 条判据,只许降不许涨:每次跑都自己种一个未用导出、knip 必须逮到(第一版拿"报了几个文件有发现"当自证,存量清零后那个数就是 0、在代码最干净时假红;样本还必须种进一个已被 import 的文件——种成新文件会被算进 unused files、和 exports 是两类);存量已清到 0 条(2026-08-21 清的:32 个全是多余的 export,摘掉 export 后 eslint 判定它们在本文件内仍在用,真死代码 0 个),基线在 tests/死代码基线.json,新增零消费者导出必红并点名是谁,清掉了也红、提醒把基线跟着降(不降的话余量会被下一个人悄悄用掉)。存量本身怎么处置归人拍板,这条只管别再长 |
tests/资源位置.test.mjs |
不打 | 找文件不许从「代码放在哪」推(lib/资源位置.mjs)。17 条判据:候选列表长得对 ≠ 真找得到(2026-08-24 逮到的「失效时是绿的」样板)—— 原来只验「候选里有一条以 lib/dedup.py 结尾」,而从 tests/ 里跑时那条是 tests/lib/dedup.py、压根不存在,判据照样绿;真炸的地方在 全 档(增量/只读要用的表 报 python3: can't open file)。现在直接验 挑存在的() 落到一个真存在的文件上,并验 direct.mjs 找不到 dedup.py 时抛的是「试过这些位置 + 该设哪个变量」那个自带修法的错,不是降级成候选第一条丢给 python3;入口目录跟着 process.argv[1] 走,把模块拷到别的目录后结果不变(这正是 import.meta.url 做不到的那点);候选顺序 = 自己的环境变量 → INVENTORY_HOME → 入口/产物旁边 → ~/.config/inventory-mcp → 调用方给的已知位置;显式指定了却不存在要抛、不许降级;找不到的报错要列全试过的路径 + 点名该设哪个变量。带棘轮:非测试代码里出现一处 import.meta.url 就红(注释里写不算),并自报扫了几个 .mjs(0 或偏小 = 没检查)。守的是打包成单文件后还能找到 plans.json/决定.json/dedup.py —— 那类错打包时不报、运行时才炸 |
tests/版本协商.test.mjs |
不打 | 握手时那一句协议版本谁说了算。12 条判据:客户端报的版本我方支持就原样回,不支持就回我方最新、不许回声(2026-08-22 修的真 bug:原来无条件把对面报的版本回过去,于是对面的兼容性检查「你回的和我要的一样吗」永远成立、永远发现不了不兼容 —— 这是典型的失效时是绿的);乱字符串、未来版本、空串、干脆不报,四种都归到我方最新;最后一条验真起到了服务器并拿到了回复(拿到 null 的话上面十一条全不作数) |
tests/新到.test.mjs |
不打 | 「离上次写台账新进来了什么」+ SN 证据(lib/台账基线.mjs / lib/新到.mjs)。14 条判据:没有基线 ≠ 没有新到(读不到基线返回 null 不是空数组,否则第一次跑会把全库算成新到、而那个数看着完全正常);按物料归并 + CSV 一根一行(清单落文件,实测一次到货 2 万根,塞进上下文会冲掉);搬库房/补品牌不算改名;SN 证据不受「老键归零」约束——源表只改一部分记录时槽位那条路一个候选都出不来、SN 照样认得出,且 SN 候选排最前 |
tests/改名确认.test.mjs |
不打 | 判改名那道闸,日更周更共用一份(lib/改名确认.mjs)。20 条判据:候选带老键原在库、备选必须跟着走(实测「最像的」有 3/28 是错的,正确前身在备选里);认定的改名 省略 ≠ 判过了都不是(省略拒写)、显式 [] 放行、编的配对拒写;命令行没有默认放行的口子(--都不是改名 / --改名 <json>);最后同时跑日更和周更两个工具,验它们拿到的是同一套闸 |
tests/日更.test.mjs |
不打 | 日更.mjs 自检回执的红检查(lib/日更检查.mjs 的 算红)。13 条判据:注册表报了新发键但条目数没涨=红、更新于不是今天=红、在途读不到=红;dry 模式跳过注册表检查、在途读不到照红;新发 0 键条目数不变不误报;占用挂在「被判成不是改名」的归零老键上=红(2026-08-20 那次 6,144 根许出去的货结不清,当时全绿),两个条件缺一不红、干跑也照红 |
tests/只读要用的表.test.mjs |
打 | 只读问到的那类资产的表 + 在途去重。11 条判据,最要紧的一条是只读一类时一个基线都不许滚(半份数据覆盖全仓 SN 快照,下次全量会把没读的二十几万根全算成「多了」,而每步守恒照样绿)。另外守着:口径要自报「只读了部分源表」、行缓存叠加不覆盖、「全部」那份缓存能答窄问题、全量要滚快照(和前一条互为反证)。消融靠两个逃生开关注入(INVENTORY_NO_NARROW / INVENTORY_NO_INFLIGHT) |
tests/写路径.test.mjs |
打,而且真写 | 写多维表格那两条命令的契约。真建一张一次性的表(ZZ-写路径测试-勿动 前缀)、写、读回、删掉,不碰任何业务表。5 条判据,两条是 2026-08-16 那次事故直接教出来的:「每条记录各自的新值真的写进去了」(不是「命令没报错」)、「拿不存在的记录 ID 更新接口照样回 ok」(把「写完必须回读」变成会红的判据)。默认跑 —— 那条写路径从写出来到崩、回归里一次都没覆盖过,「只在改了写路径时手动跑」等于永远不跑。请求体的形状从 lib/ledger-write.mjs 那两个函数拿,不在测试里手写一份 |
tests/protocol.test.mjs |
打 | initialize / tools/list / tools/call 这条链能不能通,外加进度通知(带没带 progressToken 的两种行为互为反证)、查SN 走文件和走内联两条路、老参数名要当场报错、记下决定走真的写盘(指到临时文件)。99 条判据 |
消融不是摆设,抓到过三次:brandMap 原来在模块加载时建成常量,测试改 BRAND 影响不到它,消融 1 是恒绿的;直读那边第一版消融把对应判据自己豁免了(ABLATE === '1' ? true : ...),三个消融里两个不变红;字面规范化那条消融第一版样本选错——拿 CX7-400G单口 vs Cx7 400G 单口 当样本,槽位解析器自己就能合,字面归一对它不承重,换成 128g(小写 g 解析不出容量)和 SFP.25G-SR.LC(点号解析不出规格)才真正只走字面这条路。
「消融不红」有两种原因,第二种更隐蔽:判据豁免了自己,或者样本根本不经过那条路径。
演一遍撞频控
INVENTORY_FAKE_RATELIMIT=99999 INVENTORY_BACKOFF_MS=1,1 ./run-tests.sh # 该印 ⏸,退 3
./run-tests.sh # 该全绿,退 0
反着跑这一遍不是走过场,2026-08-16 一次就抓出四个真问题,三个是同一天刚写的那套频控机制自己的:
收尾()原来要求「挂的每一条都是频控」才算没验成 —— 而分段读挂 5 条只有 1 条带码, 其余是拿不到数据之后的连锁反应,于是专门为频控做的机制在真频控面前不生效。direct.test.mjs里验退出码的子进程直接{...process.env}传下去, 把注入用的变量也带了进去 —— 一个不打网络的测试因为别处的注入而变红,测试的沙盒漏了。protocol.test.mjs十四处裸JSON.parse(…content[0].text),工具回人话时抛的是Unexpected token '查', "查不了:wiki 换"…—— 原文只剩前十个字,而code=99991400在第 40 个字上。写路径.test.mjs自己那份cli()是裸pexec,lark-cli 非 0 退出时错误体在 stdout 里、 而那个对象根本没被读,报错只剩一句Command failed:。它要真写生产 base, 恰恰是最容易撞上限额的那条。
四个的共同点:都是"失效时是绿的/黄的",而且都只有把场景真造出来才看得见。
还没做的
登记占用没做(写「占用记录」表
tbl8GkzD4stgYcay)。物料表的写入已经做了(./导台账.mjs), 源表一律只读。三条硬规矩先落地:agent 不能填「核验人」、写带请求 ID 且写前查重、 写前重读发现已被占就中止。这张表 2026-08-15 探过一遍,四件事和原来记的不一样:① 占用和出库是同一张表的两种「类型」,不是两张表。靠
带符号数量加减(占用+数量、 出库-数量),剩余占用是公式算的净额。出库那行还要在关联工单里指回它冲销的那条占用行 (实测:29124 占用 +2 → 29154 出库 -2 指着它 → 剩余占用 0、状态 ⚪已结清)。② 物料已经是 link 字段(
关联物料),不是 text。本行物料键是从它算出来的公式, 所以人是从下拉里选物料、不是手打 40 个字符 —— 原来记的「现在是 text」已经过期。③「link 走 API 会静默丢数据」不成立(2026-08-15 实测写了一条再删掉):
+record-batch-create传关联物料: [{"id":"rec..."}]写得进去,回读拿到的 link 值一字不差,而且公式本行物料键真的算出了光模块|海光芯创|QSFP112-400G-DR4-SM1310|闵行—— 关联是活的,不是存了个空壳。 形状写错时接口是显式报错(800010701 Cell value does not match any supported shape), 还在 hint 里给出正确形状,不是悄悄吞掉。④ 但返回体不给 record_id:
+record-batch-create回ok:true而records是空数组, 从返回值看不出写没写、写成了什么。所以「写完必须回读校验、不认返回值」那条规矩在这张表上 不是保险是必需。删记录要--yes。写之前的自检有现成的:这张表有个
数据检查公式字段,把规则全写在里面了 —— 没填类型 / 没选物料(「这笔占用谁都扣不到」)/ 数量要大于 0 / 同一工单同一物料写了多行 (「剩余占用会偏小」)/ 冲销超过占用量 / 缺日期 / 缺成本归属 / 出库没选关联工单 / 出库未填闭环状态。写完回读这个字段就知道对不对,别在代码里把规则再实现一遍。物料键的第三段还是型号字符串,只做了字面层归一。纯打字差异(大小写/分隔符)已经挡住了, 但语义相同、字面不同的仍会各成一个键。根治是把键换成槽位序列化、型号字符串降级成显示名。 实测 26 个合并组里有 9 组的标准写法是靠根数决出来的(
CX6-25G双口(287)vsCX6-25G*2(158), 差 129 根,一次出库就能翻盘),翻了之后周对比会报出假的「消失 + 新增」。网卡不做规则替代(2026-08-15 定,不是待办)。
方向.网卡三项全是null,所以它的 规则档必然是空的 —— 能不能拿 CX6 顶 CX5、双口能不能顶单口是硬件常识,槽位表算不出来, 拍这张表的成本高于收益。空数组必须带解释:不做规则替代那张表命中时返回值里明说 「这类不做规则替代 + 为什么」,和「⚠ 这类的替代方向还没定」互斥 —— 后者读起来是个待办, 会让人一直等一个不会来的东西。网卡照常给精确匹配和相近分层。要开做就删掉那一项、 把方向填上,两处一起动(lib/substitute.mjs,判据 ㉟㊱㊲ + 消融 9)。配额不是障碍,已查清。飞书没有「月度额度」这回事 —— 官方
frequency-control页给的是按 API × 应用 × 租户的每分钟/每秒速率,最窄一档 100 次/分,基础版和商业版的表几乎一样。触发了回 HTTP 429 +code 99991400,响应头x-ogw-ratelimit-reset直接告诉你等几秒。但直读这条路够得着:31 张源表,一次冷启动约 48 次(30 张各一趟 + 闵行光模块那张 1 次元数据 + 17 段),空缓存再多一趟表头。一分钟内连开两次冷启动就撞线,撞上后从 8 秒掉到 1921 秒 —— 这条不是理论,2026-08-14 排查读表慢的时候踩到过,当时误以为是数据量。 热态只有 9 次(查 9 本文档的 revision),台账那条路 12 次。三个人各装一份、各自零星查够不着, 但别在一分钟内连跑两次全量。段数越细调用次数越多,改每段格子之前先算这笔账。