M-perf · 端到端延迟审计:召回收敛、上下文上提与诚实度加固#
面试向开发文档:讲「为什么这么做、取舍在哪、量到了什么」,不写函数签名与代码细节。 这一轮起于一次性能审计,但产出横跨延迟与答案质量/诚实度两条线——见下文「一句话」。
一句话#
线上一条「露营厨具」query 端到端跑了 295s,我去查为什么慢。埋点后发现两件事:①真正的成本 几乎全在模型解码(reasoning),工具只占 ~12s;②这条慢样本同时还是一条错样本——预算被猜错币种、 答案降级、甚至拿品类范例编造商品。于是这轮实际做了两摊事:砍延迟(让子 Agent 别瞎搜、别重复 解码)和修诚实度(货币确定性、空召回不幻觉)。最有价值的不是某个优化本身,而是几个反直觉的 实测结论:over-search 与库大小无关、瓶颈会转移、relevance floor 基本没用、换小模型是负优化。
方法论:先量,再改#
审计的第一步不是改代码,是埋点出基线。复用已有的 AGUI 事件流(每个工具的 start/end + 每次 请求模型的 assistant_call),算出「调用数 / 各段耗时 / 解码间隔」。基线告诉我:
- 单任务 295s / 42 次 LLM 调用,assistant_call 间隔 29s/15s/14s/10s ——~96% 是 reasoning 模型逐字解码,全部工具加起来才 ~12s。成本在解码,不在工具。
- 一个被以为很慢的工具(category_insight)实测只要 0.6s——「160s」是把时序表的「起始时刻」误读成 「时长」。这条让我砍掉了一个原计划里的伪优化(给它加缓存)。
教训:没有基线就改性能 = 拍脑袋。这轮砍掉的伪优化、和后面发现的「瓶颈转移」,全靠先量。
观测升级:从 AGUI 估算到 Langfuse 全链路 trace#
最初的基线靠 AGUI 事件流估算,但 AGUI 是给前端用户看的,只覆盖主链路(主 loop 的工具
start/end + assistant_call)——看不到 fork 子 Agent 内部,也看不到「工具内 LLM」(planner /
shopping_summary 是 LLM 驱动工具,它们自己那次解码藏在工具 span 里)。这轮把 Langfuse 接进主链路
(父子 span 经同一 trace_id 归并:一轮带 3 平台 fork 的对话原本散成 4 条 root trace,归并成 1
条),第一次拿到一次请求的完整 LLM 调用树:
- 一次「跨平台推荐」请求 = 19 次 LLM 调用,而主链路埋点只看得到 8 次——差的 11 次正是 3 个 fork 子 Agent(各 3 次)+ 2 个工具内 LLM(planner / shopping_summary)。没有全链路 trace,等于对一半的解码成本视而不见。
- 时间戳实证了并行:3 个子 Agent 的首次检索起始时刻同一毫秒,fork 段墙钟 = 最慢的子(11.4s)而非 三者之和(27s)。「fork 在省时间」从估算变成了实测。
这条的价值:观测要分层——AGUI 给用户看「Agent 在干什么」,Langfuse 给开发看「钱花在哪个 span」。两者覆盖面不同,缺了后者就量不准解码成本。
延迟线#
1. 平台无关上下文上提 + 深度闸(机制收口,不靠 prompt)#
问题:跨平台检索时,主 Agent fork 出 N 个同质子 Agent(每平台一个)。每个子都自己跑一遍
planner(意图拆解)和 category_insight(品类常识)——可这两件事与具体平台无关,N 个子重复
做 N 遍,纯属重复解码。
做法:把 planner / category_insight 上提到主流程跑一次,结果塞进 fork 的 demands;子拿到
现成结论,直接检索。关键在怎么保证子不再自己跑——不是在 prompt 里写「请不要再调」(弱模型
不听),而是用深度闸:这两个工具被钉成 depth==0 only,子(depth≥1)调用即被中间件硬挡。
一个超出原计划的决定:原计划只钉 planner。我把 category_insight 也钉了——因为收益(砍掉 ~2N 次子调用)需要两个都收口,且「靠 prompt 软提示、弱模型不听」这个理由对两者一样成立。这是 对「机制兜边界、不靠 prompt」原则的一致贯彻。
取舍:子从「能自己拆意图+查品类」退成「直接检索」,更接近传统的「搜索单跳」。这是有意识的 回退——把平台无关的脑力上提到主流程集中做一次,子只干平台相关的脏活。
2. 检索收敛:预算可见 + 耗尽夺权 + 找够用#
这是这轮最该讲的一块,因为它纠正了我自己一个错误的第一直觉。
现象:子 Agent 疯狂重搜。一个平台它能搜 5-6 次同一个意图(只是换关键词),迟迟不收敛。
我的第一直觉(错的):「是不是库太小、搜不到,才一直找?」做了对照实验证伪了它:换一个 库里覆盖充足的品类(跑步鞋,库里几百条),子照样搜 5-6 次,而且最终答案是优质的(真找到了好 跑鞋)。搜索次数与「结果好不好、库有没有货」完全解耦 → 这不是数据问题,是模型「再找找更好」 的贪心动机:它不满足于「够用」,总想再搜出更优的。
正确的收敛办法(三杠杆,刻意不靠「让模型遵守计数」):
- 找够用 not 找最优(prompt 改目标):把子目标从「找最合适的」改成「找到够用的就返回」。这是 治本——over-search 的根是目标被理解成了「优化」而非「满足」。
- 预算可见(把预算暴露给模型):每次检索结果尾部告诉它「本平台已搜 X/2 次,够用即返回」。让 模型带着预算规划,而不是被硬挡时才发现。
- 耗尽夺权(到点把工具从模型手里拿走):搜满上限后,在「请求模型前」直接把检索工具从它能 看到的工具表里摘掉——它根本看不到、也就不会再调,被迫用已有候选收尾。这比「返回一条错误让 它自觉停」硬:后者还浪费一轮解码去发那个注定被拒的调用。
一个踩坑:「摘工具」是 per-model-call 的,实测有 run-to-run 变体——某次主 Agent 在一轮里批量 并行发了多个检索调用、或模型行为抖动,就会漏。所以我又加了一道执行层兜底:即使调用漏过来 了,工具执行前再硬拦一道。这印证了一个经验:对抗模型的不确定性,光靠「请求前过滤」不够,得在 「执行出口」再兜一次。
⚠️ 后续修正(与当前代码一致):上面的「摘工具(请求前从工具表拿掉)+ 执行层兜底」双保险,后来 为了
M6.1 跨轮前缀缓存治理把「摘工具」这条撤掉了——工具 表一变就打断 prompt cache 前缀。现在禁用/收敛统一走执行层哨兵、工具表恒定(见 middleware 类 docstring 当前口径)。代价是哨兵拦执行不拦 decode(模型仍会为被拒的调用花一次解码),换的是缓存 前缀稳定。这个「摘工具省解码 vs 破前缀缓存」的取舍在Mperf.1的 over-loop 治理里又原样撞了一次—— 它是个按场景权衡的取舍、当前默认选保缓存,不是「绝不改工具表」的铁律。
还有一个挤气球效应:把子的 over-search 堵住后,「再找找更好」的动机改道冒到了主 Agent 的直接检索上(子被压到 2 次,主 fork 完又自己直搜了 5-6 次)。所以同一套机制也对主 Agent 生效: 主一旦跑过跨平台并行 fork,就把它的检索工具也收走——fork 就是检索阶段,之后该收尾。
效果:子检索 5-6→2、主 fork 后直搜→0、单任务总检索 13→7。
再进一步:连「纠偏」名额也是浪费(cap 2→1)#
上面把子的单平台检索压到 2 次(1 初搜 + 1「纠偏」重搜)。这轮借 Langfuse 把那 2 次的结果 摊开看,又一个反直觉发现:第二次几乎是白搜。三个平台两轮 item_search,top1 候选全部没变, 整体 75-80% 重叠(amazon/lazada 各 20 条交集 15-16;shopee 召回池小,top1 也没动)。根因:底层是 同一个向量库,两次 query 只是近义改写(「iPhone 20W USB-C PD 充电头」↔「20W PD 快充 for iPhone 14 15」),embedding 高度相似 → 召回近乎重合。模型以为「换个词能挖到新货」,但 dense 召回对 近义改写不敏感。
而且实测三个平台第一次就召回非空——那个「纠偏」名额本该留给「第一次搜空了」的情况,却被 「已经搜到了」的子无条件花掉,白烧一次 LLM + context 翻倍(子 input 6k→9k→12k 一路涨)。
做法:把单平台 cap 从 2 收到 1,强制「单平台恰好一次召回」,并把全树检索预算 15→8 同步收 紧。沿用同一套「耗尽夺权」机制(搜满即把 item_search 摘出工具表),不加新逻辑、只调一个常量。 取舍:牺牲「第一次搜空的重搜补救」,落到 web_search 兜底门(召回全空才放行)+ prompt「没货 如实说」兜底。这是 #2「找够用 not 找最优」的极致——够用的下限就是「搜到了就停」。子检索 再 2→1,一次跨平台请求的 LLM 调用 19→16。
3. 模型分层 / 关 reasoning —— 一个诚实的负面结论#
审计说 96% 是 reasoning 解码,那最大的杠杆理应是别让所有环节都开 reasoning。子 Agent 在前两步 之后已经塌成 1-2 跳检索、不需要深推理;shopping_summary 只是按既定候选写文案。这两处换「快档」。
第一版思路(错的):换一个更小更快的模型。 实测翻车:把子换成 qwen-turbo,更慢(140s vs 96s)且降级(在覆盖充足的跑鞋库上反而答「没找到」);换 qwen3.5-flash 直接跑崩(>530s 没收尾)。 弱模型为了完成同样的任务要更多轮、且自纠偏判断更差——省下的单步解码,被暴涨的轮数和返工吃光。
第二版思路(对的):同一个模型,只关 reasoning。 现在的主模型(DeepSeek V4 这类 hybrid)本身能 关思考。同模型关推理:能力不掉(还是那个模型),只省掉昂贵的 thinking 解码。单次调用实测 5.6s → 1.0s(5.6×),质量不降。
但端到端总时长几乎没动。 相位分解揭示了原因:子执行被压到 ~7.5s 后,瓶颈已经转移——时间 几乎全在主 loop 的逐步 reasoning(planner + 每个工具前那次思考,~15 次,每次 5-15s)。换句话说: #1/#2 把子压扁后,子早就不是延迟大头了,主 loop 才是。 只关子的推理,对总数是隔靴搔痒。
这一段的价值不在「省了多少」,而在它暴露了瓶颈的迁移:性能优化是打地鼠,堵住一个洞,成本会 顶到下一个最软的地方。下一步真正的杠杆是主 loop(工作流高度脚本化,主 loop 的「思考」很可能低 价值),但那是 speed/robustness 的取舍,留作后续。
Langfuse 实证了这个判断:一次跨平台请求 62.4s,相位分解为 **fork 前主 loop 24s(3 次主 LLM)
- fork 并行段 11.4s + fork 后主 loop 27s(6 次主 LLM)**。主 loop 10 次串行 LLM 占总墙钟 81%; 工具本身近乎免费(price_compare / shipping_calc / item_picker 各 1-3ms,category_insight 的 RAG 检索 0.6s)。解码吞吐仅 ~72 tok/s——这 62s 不是「写得烂」,是「flash 解码速度 × AgentLoop 串行 步数」的乘积。上面 cap=1 省的是 fork 并行段里的子 Agent 第二轮(3-4s),对大头(主 loop 50s 串行)动不到。再次印证:子早已不是大头,主 loop 串行才是,而它受限于解码速度本身。
后续相关优化——URL 旁路登记表:既然解码量=延迟,那「模型回吐多少」也是杠杆。候选在工具间靠 LLM 当参数一路透传,每跳都被模型回吐——其中 url / image_url 又长又对推理无用(只供前端卡片)。
做法是一套**「登记→剥除→回填」三步**(_candidates.py):① item_search 召回后把完整候选(含真实 URL)按 item_id 登记到会话级注册表;② 下发给模型的候选形态剥除 url/image_url(紧凑 JSON);③ 收尾时 shopping_summary 按 item_id 从注册表回填真实链接,给前端商品卡。
这么做除了省 token(单次检索 ~25%,四跳透传的输入+输出都省),还堵了一个链接幻觉风险:模型在回吐候选时偶尔会修改/拼凑 URL,产出「看着像真链接、实际 404」的幻觉。旁路让 URL 完全绕过模型,杜绝这个口子。注册表的写入是防御性 upsert——只在新值有真实 URL 时覆盖,避免下游工具返回的「已剥除 URL 的候选」反把注册表里的真值冲掉。
后续泛化(见
Mperf.1):这个登记表这里只剥了 url/image。下一轮把它升级成候选的唯一真相源——工具间只传 item_id、工具内按 idhydrate回全量候选(不止 url,整包候选都不再当参数穿过模型),把「模型回吐候选」这条彻底堵死。item_picker / shopping_summary 那两步的 tool-call 参数从 ~1958/2897 字符降到 ~300/258,loop 104.6→81.1s。
诚实度线(查延迟时顺手挖出的质量 bug)#
那条 295s 的坏样本不只是慢,还是错的。拆开发现「慢」和「错」是两条独立的线,都得治。
4. 货币确定性:别让模型每轮自由猜币种#
问题:用户说「预算 500」没给币种。planner 用 LLM(temperature>0)自由换算成 USD——同一句话 每轮猜出不同币种(₹/¥/$),预算 500 一会儿被当 $500、一会儿 ¥500≈$69。后者直接导致「预算内 空召回」→ 答案降级成「抱歉没找到」。这既是正确性 bug,也是性能测量的不可比来源(每跑预算都 不一样,没法对照)。
做法:把币种解析从「模型自由猜」改成纯规则确定性解析(正则抓 $/¥/€/£/₹ 等符号,带消歧
顺序:S$→SGD 先于裸 $→USD、美元/欧元/日元 先于「元」→CNY),解析不出才落钉死的默认币种
(CNY),并用静态汇率表确定性折算。再在答案里诚实标注「已按人民币理解(约 $X),不对请告诉我」。
取舍(用户拍的板):默认 CNY 意味着「预算 500」→¥500≈$69,正是当初的坏样本场景。接受它, 但靠两道兜:答案强制标注(可纠正)+ 下面 #5 的空召回诚实降级(不静默崩)。确定性 + 诚实标注 比「猜得偶尔对」更可取——可复现、可纠正。
5. 空召回硬路径 + relevance floor —— 又一个诚实的负面结论#
问题:库里没有「露营厨具套装」时,模型有时跳过检索、直接拿 category_insight 的品类范例当 候选去比价,编出一份看似合理实则幻觉的清单。
第一版思路:给召回加 relevance floor。 dense 召回永远返回 top-k 最近邻不管多远,库里没货
也吐一堆「最近的垃圾」,total_recall=20 假装召回满满。用相似度分设个下限,让纯垃圾召回如实报 0。
实测打脸了这个思路的有效性:探了真实分数分布——BGE-M3 在这个千级杂货库里给任何真实英文 query 都打 ≥0.48(连「挖掘机/处方药/活体金鱼」这类库根本没有的也 0.48-0.53),只有纯乱码 ≤0.40; 而「库稀缺但沾边」0.49-0.60、「命中良好」0.56-0.70 —— 三段严重重叠,单一绝对阈值无法把「缺货」 和「有但一般」分开。所以 floor 实际只挡住乱码级无关,挡不住「品类缺货」。我把这个局限 诚实地写进了代码注释和 .env,没有假装它能解决问题。
真正起作用的是 prompt 层诚实:① shopping_summary 红线——只汇总真实候选,picks 空就如实说 「没找到」+ 建议放宽,绝不编造;② 空召回硬路径——全平台空时不绕 category_insight/chat_fallback 兜圈,直接诚实收尾,绝不拿品类范例编造商品。实测对抗 query 现在诚实降级(「四平台检索后 没找到 + 原因分析」),不再幻觉。
这一段的教训:相关性阈值在异构杂货库 + 通用 embedding 上天然不可靠(语义空间太密,什么都 沾点边)。与其追求一个「能判缺货」的神奇阈值,不如承认它判不了,把诚实兜底交给明确的 prompt 红线。
背景护栏(上一轮已有,本轮验证复用)#
延迟能从 295s 砍下来,护栏的贡献被低估了。上一轮已落地的 fork 安全机制——深度上限(只允许 一层 fork)、树级 fork 预算(跨平台并行只放一轮)、全树检索总量预算(主+子的检索计进同一 计数,过阈值强制收敛)——本身就把「失控的 12 次 fork / 反复检索」摁住了。这轮的 #2/#2.5 是在它们 之上做更精细的收敛。真正把 295s 砍到 ~80-130s 的主力是护栏 + 召回收敛,不是模型分层。
几条工程心得(面试可复述)#
- 先量再改。没有基线的性能优化是赌博。基线让我砍掉伪优化、发现瓶颈转移。
- 机制兜边界,prompt 只当辅助。弱模型不听「请不要……」。职责边界(谁能调什么)、收敛(搜够了 停)都得用机制(深度闸 / 摘工具 / 执行层硬拦)兜死,prompt 用来打「动机」(找够用)、不打「机制」。
- 预算可见 + 耗尽夺权 > 让模型遵守计数。把约束暴露给模型让它规划,到点直接收走能力,比指望 它自觉数数靠谱。
- 对抗模型不确定性要双保险。「请求前过滤」会被批量并行调用 / 行为抖动漏掉,得在「执行出口」 再兜一道。
- 优化是打地鼠,瓶颈会转移。堵住子的 over-search,动机冒到主 loop;压扁子的解码,瓶颈到主 loop 推理。每堵一处都要重新量下一个软肋在哪。
- 诚实地接受负面结论。换小模型是负优化、relevance floor 判不了缺货——这些都如实写进代码注释 和文档,不粉饰。一个能跑但带假象的优化,比没有更危险。
- 观测要分层,trace 比埋点更不会骗人。给用户看的事件流(AGUI)漏掉子 Agent 内部和工具内 LLM ——只它估算,等于对一半解码成本视而不见。量成本要用能看到每个 span 的全链路 trace(Langfuse), 它还顺手实证了「fork 真并行」「主 loop 才是瓶颈」这些原本只能靠推断的结论。
取舍与遗留#
- 模型分层默认关(
LLM_FAST不配 = 行为不变),因为实测在当前网关上无净收益;留作可选基建, 将来有又快又强的快档再一键开。代码在独立分支,便于回滚。 - 总延迟的下一个杠杆是主 loop 的 reasoning,不是子。但那要权衡路由/纠错的鲁棒性,未做。
- 「缺货」检测在通用 embedding 上不可靠,目前靠 prompt 诚实兜底;要真做得换带 ground-truth 的 相关性判定(如 ESCI 数据集 + 专门的相关性模型),属另一档工作。
- 数据稀疏是露营样本的根因:库里确实缺露营厨具。再多机制也变不出货,只能诚实降级。