M-perf.1 · 候选 id 化传递 与 over-loop 哨兵治理(第二轮延迟)#
面试向开发文档:讲「为什么、取舍在哪、量到了什么」,不写函数签名与代码细节。 承接
Mperf-端到端延迟审计。上一轮定位到 瓶颈=模型解码、且主 loop 串行才是大头;这一轮不换模型、不改流程,专门把解码量本身往下压。
一句话#
上一轮的结论是「延迟 ≈ flash 解码速度 × AgentLoop 串行步数」。这一轮从这条公式的两个乘数同时下手: ① 砍每一步的解码量——候选别再当工具参数逐字重吐(登记表从「URL 旁路」升级成「候选唯一真相源」, 全链路只传 item_id)、shopping_summary 内部 LLM 只写文案不重吐已知字段;② 砍步数——治 over-loop (模型收尾后还打转、fork 后还直搜,每次都白烧一个解码步)。最有价值的一条是:over-loop 我先用「摘 工具」治、更省,却因为它打断前缀缓存、按当前默认回退成执行层哨兵——而这正是项目在上一轮检索收敛时撞过 的同一个岔路口。「摘工具省 decode vs 哨兵保缓存前缀」是个每次都要重新称量的取舍,不是非此即彼的铁律。
抓手一:候选不再当工具参数逐字重吐(id 化传递)#
复审发现的浪费#
拿一条「通勤背包」query 复审,loop 104.6s 仍是 100% 解码。逐条看主 loop 的 output token,发现两步异常
大:item_picker 那一步的 tool-call 参数 1958 字符 / out 1282 token、shopping_summary 那一步
2897 字符 / out 1213 token。原因是这两个工具签名收 list[候选对象]——模型要把整包候选逐字重新
生成成 JSON 参数,等于在重吐它上下文里已经有的东西。而 input 侧 prompt cache 命中良好(cache_read
一路涨),瓶颈纯在 output 解码。
登记表从「URL 旁路」升级成「候选唯一真相源」#
上一轮已经有一个会话级候选登记表(_candidates),当时只用来做 URL 旁路:召回时把含真实 URL 的
候选按 item_id 登记,下发给模型的候选剥掉 url/image,收尾再按 id 回填——省的是 url 那几个字段。
这一轮把它泛化成候选的唯一真相源:模型在工具之间只传 item_id 列表,工具内部按 id 从登记表
hydrate 回全量候选。各阶段(price_compare 填 price_usd、shipping_calc 填 landed_usd、item_picker 写
pick_reason)算完就把渐进字段回写登记表,保证下游按 id 捞到的永远是最新全量候选。原来的
list[候选对象] 参数降级成对模型不可见的注入参数(InjectedToolArg)——模型侧的工具 schema 里
只剩 item_ids,结构上就不可能再重吐候选;而直接调用 / 单测仍可注入现成候选,测试几乎不用改。
量化#
同一条 query 前后对照:item_picker 参数 1958→306 字符、解码 16.74→7.89s;shopping_summary
参数 2897→258 字符、解码 14.32→3.34s;agent loop 104.6→81.1s(−22%)。收尾清单的
标题/价格/url/理由全对(来自登记表 hydrate,非模型生成)。
这里有个和 URL 旁路一脉相承的附带收益:候选彻底不过模型,就再没有「模型回吐时改写/拼凑字段」的 幻觉口子——URL 旁路当初堵的是链接幻觉,id 化顺带把标题、价格也钉成确定值。
收口:全链路统一#
第一版只改了复审里最烫的两个工具(item_picker / shopping_summary)。随后把 price_compare /
shipping_calc 也转成 id-based——它们被调用时同样会重吐候选。至此
item_search → price_compare → shipping_calc → item_picker → shopping_summary 全链路只传 id、登记表
是唯一真相源,任何一跳都不再有候选整包穿过模型。
抓手二:shopping_summary 内部 LLM 只写文案、不重吐已知字段#
id 化砍掉了主 loop 把候选当参数重吐;但 shopping_summary 是 LLM 驱动工具,它内部那次 LLM 仍在 逐件重吐 title/platform/到手价(实测内部执行 ~11.75s)。这些都是候选里已知的确定字段,让模型重新生成 既费解码、又平白引入「改写标题 / 生成到一半截断」的风险(实测出现过半句理由进商品卡)。
做法:把内部 LLM 的产出契约收窄成只有「开场白文案 + 每件一条选购理由(按 item_id)」;商品卡的 item_id/platform/title/到手价/图/链接收尾时按 id 从候选确定性组装,展示顺序 = item_picker 已排好的 picks 顺序;理由缺失或被截断则用候选自带字段兜底。量化:内部执行 11.75s→~3.6–4.4s(~3×),理由质量 不降(仍是 LLM 叙事、贴合意图,如「单肩非双肩 / 接近 16 寸」这类判断都在),而标题/价格因为不再过模型、 杜绝了改写与截断。
这条的思路和抓手一同源:凡是确定的字段,就别让模型解码它——不管是当工具参数(抓手一)还是当结构化 输出(抓手二)。
抓手三:治 over-loop(哨兵,不摘工具)—— 一次「重踩旧坑」的复盘#
诊断#
一条病态跑 133.9s,Langfuse/history 摊开看是打转:主 loop 跑了 12 步解码(健康只需 6 步)。多出来的
步来自两类:① 收尾后还打转——调完终结工具 shopping_summary 之后,模型又去 item_search / 再
item_picker / 再 summary;② fork 后还直搜——跨平台并行 fork 明明已经是检索阶段,模型 fork 完又在主层
直接 item_search「再找找更好」。这两类每发生一次,就多烧一个昂贵的主 loop 解码步。
关键观察:这两类行为既有护栏在执行层本就硬拦(post-fork 直搜有 _MAIN_POSTFORK_SEARCH_DENIED 哨兵、
二次 fork 有 fork 预算、终结有收尾提示词)。但执行层哨兵拦的是「工具执行」,拦不住模型「已经把这次调用
解码出来」——模型 decode 出一个注定被拒的调用、吃一条哨兵、再 decode 下一步,每次无效尝试仍烧一步解码。
走过的弯路:摘工具(更省,但破缓存)#
第一版顺着「那就别让模型看到这些工具」的直觉做:在模型调用前按状态摘工具——已调终结工具 → 摘掉全部
工具逼它只出收尾文案;并行 fork 跑过 → 摘掉检索/fork 工具逼进收尾链。效果很好,轨迹回到干净 5 步、65.3s。
但它打断了 prompt cache 前缀——工具表一变,从变化点起后面的 KV cache 全作废,直接和
M6.1 跨轮前缀缓存治理 冲突。
回退:执行层哨兵(保缓存前缀)#
按当前默认(工具表恒定、走执行层哨兵以保缓存前缀)回退摘工具,改用哨兵治终结硬停:主 loop 真实执行过终结
工具后置一个标志,之后任何工具调用(含再调终结工具)在执行层入口一律拦下、回一条「本轮已收尾,请
直接输出收尾文案」的哨兵,断掉收尾后的打转尾巴。fork 后直搜那条本就有 _MAIN_POSTFORK_SEARCH_DENIED
哨兵,回退摘工具后它自然接管、无需新代码。工具表全程不变 → 前缀缓存不破。实测收尾后干净终止、
一条被 post-fork 哨兵拦下的 item_search 后即用 fork 候选收敛,79.3s(比摘工具版 65.3s 略慢——那几 s
正是「哨兵拦执行但不拦 decode」的代价,换回的是缓存前缀稳定)。
这轮真正的价值:一个反复出现的取舍,写清楚而不是设铁律#
上一轮延迟审计的检索收敛其实用过一模一样的「摘工具」做法(见 Mperf §2 早期描述),后来为了缓存 前缀被换成了执行层哨兵——只是文档没跟上。我这轮在终结硬停上又独立地撞上同一个岔路口:摘工具能多省 解码(65.3s),执行层哨兵保缓存前缀(79.3s)。这不是「对/错」,是一个每次都要重新称量的取舍:
摘工具 vs 执行层哨兵,本质是「省 decode」和「保 prompt cache 前缀」之间的权衡。
- 摘工具:模型 decode 前就看不到被禁工具 → 省掉无效解码;但工具表一变就打断前缀缓存。
- 哨兵:工具表恒定 → 缓存前缀稳定;但只拦执行不拦 decode,模型仍会为被拒调用花一次解码。
当前默认选哨兵,因为这个项目把跨轮/跨会话的前缀缓存命中看得较重(见 M6.1)。但这是默认、不是禁令—— 若某条链路的解码浪费明确压过缓存收益(比如某个高频、必然被拒、且缓存本就命中不佳的调用),摘工具仍是 正当选项,按场景重新称量即可。
哨兵的固有边界也要诚实标注:它是 reactive 的(位于模型决策的下游),只能否决执行、不能阻止模型产生 调用,也不能结构性封死步数——只能逐次拦 + 推收敛,最终步数上限靠 recursion limit 兜底。这是选哨兵时 明知并接受的代价,不是 bug。
几条工程心得(面试可复述)#
- 确定的东西别让模型解码。候选当工具参数重吐(抓手一)、已知字段当结构化输出重吐(抓手二),本质 都是「模型在重新生成它已经拥有的确定信息」。id 化 / 确定性组装把这些从解码里挪走——延迟=解码量时, 这是最直接的杠杆。
- input 缓存命中 ≠ 没问题。这轮的浪费全在 output 侧,input 的 prompt cache 一直命中良好。量延迟要 分清 input(prefill,便宜、可缓存)和 output(decode,贵、是瓶颈)。
- 摘工具省解码 vs 哨兵保缓存前缀,是取舍不是对错。摘工具能省无效解码但打断前缀缓存;哨兵工具表 恒定、保缓存,但只拦执行不拦 decode。当前默认选哨兵(项目看重缓存命中),但这是可按场景重新称量的 默认、不是禁令——某条链路解码浪费明确压过缓存收益时,摘工具仍是正当选项。
- n=1 墙钟噪声大,归因要看分步。over-loop 是 flash 的随机行为,某一跑本就不打转。三个抓手的效果要 看「该步的解码时长 / 该工具的内部执行时长」这种确定性归因指标,而不是波动巨大的总墙钟。
- 反复出现的取舍值得写清楚,但别写成铁律。同一个「摘工具 vs 哨兵」的岔路口项目撞了两次——把两边的 代价和「当前默认怎么选、为什么」记进文档,比留在某次 commit 里更省事;但记的是取舍的账,不是「绝不 改工具表」这种一刀切禁令。
取舍与遗留#
- id 化的副作用:空 item_ids。改成传 id 后,模型偶尔漏传、发一个空 item_ids 的调用,白跑一步再重搜。 实测被 over-loop 哨兵收紧上下文后未再现;真复发可在工具空输入时回一条「请传上一步候选的 item_id」指引 (未做)。
- 哨兵不结构性封步数。它逐次拦、推模型收敛,步数上限最终靠 recursion limit 兜底;换来的是缓存前缀 稳定。要既省 decode 又保缓存,得靠更根本的手段(如把高度脚本化的主 loop 部分环节改成确定性编排, 少一次「思考」少一步解码)——那是 speed/robustness 的取舍,留作后续。
- 终结硬停用实例标志:跨 loop 步无竞态(图的 model↔tools 节点严格交替,标志置位在前、读取在后), 唯一的窄缝是「同一条 AIMessage 里并行发多个 tool_call」同批执行时的时序;实测未出现,要收死可改读 历史消息里有无终结 ToolMessage。