面试知识库

M9 · 主 AgentLoop 组装(合龙)#

面试向开发文档:讲「为什么这么做、取舍在哪」,不写函数签名与代码细节。 配合 ROADMAP.md 的 M9、refdocs/14 阅读。

一句话#

把前八个里程碑攒下的零件——九工具、同质 fork、上下文压缩、长期记忆、AGUI 监控—— 焊成一条能从「用户一句购物意图」一路跑到「带理由的清单 + 偏好沉淀」的主链路。这是整个 项目的「合龙」节点:之前每块都单测透了,这一步让它们第一次真的一起转起来

背景:合龙到底要解决什么#

前面的里程碑都是「先把原语做对、做透,但先不挂主 loop」。比如 M2 的 fork 安全四层、M6 的 压缩中间件、M7 的记忆读写函数,都明确写着「真正挂进主链路是 M9 的事」。原因很简单:主 loop 是唯一一个同时需要这些能力的挂载点,没有它,单独挂任何一个都是空挂。

所以 M9 的工作不是写很多新逻辑,而是接线:在一个入口函数里,按正确的顺序、正确的上下文, 把这些零件串起来,并补上最后两个收尾工具的「装配」。难点不在代码量,在于接缝处的正确性 ——上下文怎么不串台、结构化数据怎么可靠地从工具流回主流程、弱模型怎么保证收尾。

关键决策与取舍#

1. 主动更正 refdocs:用真实依赖的装配方式#

refdocs/14 给的主 loop 代码是「扒来的」,用的是已经废弃的 create_react_agent + post_model_hook

  • 一个全局 store 单例。本仓库的真实依赖是 LangChain 1.x,对应的是 create_agent + 中间件机制 (这套在 M1、M6 已经用熟)。所以我没有照抄,而是按真实 API 重写:压缩 / 截断 / 循环检测全部走 中间件挂载,记忆走 M7 的「按 env 选后端」的 Store。这正是项目的铁律——对齐框架与思想, 不照抄代码

2. 同质 fork 落到中间件层:主 / 子用「同一套装备」#

「同质 fork」要求子 Agent 是主 Agent 的完整克隆。之前已经保证了「同一份工具集 + 同一份 system prompt」。M9 把这个约束延伸到中间件:主 loop 和 fork 出去的子 loop 挂的是同一个工厂 产出的同一套中间件(压缩 + 截断 + 循环检测 + 检索预算)。

这里有个容易踩的坑:循环检测器是有状态的(它要记住最近调了哪些工具)。如果图省事做成全局 单例复用,两个并发会话的计数就会串台——A 会话的搜索会让 B 会话误判成「在打转」。所以工厂每次 新建一份,每个 Agent 实例独享自己的计数器。这是「有状态的东西绝不跨实例复用」的一个具体例子。

3. 收尾工具的结构化数据怎么可靠回流:content 给人看,artifact 给机器用#

这是 M9 最值得讲的一个工程细节。

主链路收尾时,shopping_summary 工具会识别出本轮的新偏好(比如「不要塑料」),主流程要把它 写回长期记忆。问题是:工具的返回值是怎么回到主流程的? 在 Agent 框架里,工具返回的对象会被 框架转成一条「工具消息」塞进对话历史,而转换默认是 str(对象)——一个不稳定、没法可靠解析的 文本 repr。靠正则去抠里面的偏好字段,是脆的。

解法是用框架提供的「内容 + 附件」返回模式:工具同时给两样东西——一段给人/模型看的可读清单 文案(content),和一个原样保留的结构化对象(artifact)。结构化对象不会被 str() 化,完整挂在 消息上。主流程收尾时直接从消息流里把这个 artifact 取出来,类型确定、字段完整,写回 100% 可靠。 额外的好处:未来前端(M10)要渲染商品卡,也能直接吃这个 artifact,不用二次解析文案。

判别「哪条消息是收尾工具的结果」时,我没有靠工具名字段(它是可选的、序列化往返可能丢),而是 直接认 artifact 的类型——只有这个工具产这种类型的对象,类型本身就是确定信号。这个选择是 code review 时一个评审角度提醒后改的:既然整套机制就是为了「可靠」,判别条件也不该留软肋。

4. <termination> 先于功能:四重护栏 + 不硬跳#

项目铁律之一:涉及 AgentLoop 的改动,收尾路径先于功能——Agent 最常见、最致命的失败就是 不收尾、反复调工具死循环。M9 给主 loop 配了四重护栏,层层兜底:

  1. 终结工具 + 收尾提示词:模型自己判断信息够了就调终结性工具,这是「软」的、最理想的收尾。
  2. 迭代硬上限:图执行的超步数有上限,到顶强制停(防真死循环)。
  3. 整体超时:单个任务跑太久直接中断(防卡死)。
  4. 循环检测 + 检索预算:发现「在打转」就给模型提示,推它换思路或收尾。

一个有意识的取舍:没有做「调完终结工具就硬跳到结束」。因为终结工具产的是结构化数据,还需要 最后一轮让模型把它转成一段面向用户的收尾文案。硬跳会丢掉这段文案。所以选择「自然终止」—— 终结工具跑完,模型再说一句收尾的话,循环自然结束。自然收口比硬跳更稳,也更符合 AgentLoop 「让模型自己决定何时停」的范式。

5. 踩的最大的坑:弱模型不收敛,靠「检索预算」强制收口#

这是 M9 最真实的一个坑,也是最能讲的故事。

接好线、第一次拿真实模型跑验收 query,结果主 loop 撞上了迭代上限都没收尾——它疯狂地反复 搜索(同一品类换着关键词搜了几十次),始终不肯进入「比价 → 算到手价 → 精挑 → 收尾」的下半段。 护栏(迭代上限)确实兜住了,没有无限循环、没有 token 爆炸——安全层做对了——但这不是一次 成功的全链路演示。

根因是模型本身弱(配置的是 flash 档的小模型),它有「再找找更好的」的强迫症。我先强化了提示词 (把工作流写成一条带编号的标准购物流程,明确「检索只做一轮、拿到候选就往下走」),有改善但 不够——弱模型对泛泛的「换个思路」提示不敏感。

最终加了一道检索预算护栏:累计「收集信息」类动作(检索 + 派发检索子任务的 fork)超过一个 软上限,就在工具结果里追加一条祈使句指令,点名「立即停止检索,依次调用比价、到手价、精挑、 收尾这几个工具」。给弱模型一个具体的下一步动作,比讲道理有效得多。加上之后,全链路干净收尾: planner → 跨平台并行 fork 检索 → 比价 → 到手价 → 精挑 3 件 → 收尾,并把 2 条新偏好写回记忆。

这里也有一个 review 抓出的深度问题值得讲:预算一开始只数「直接检索」,漏了 fork。但跨平台检索 恰恰是走 fork 的,而且每个 fork 出去的子 Agent 是新的计数器——模型只要改用「再 fork 搜一遍」 就能绕过预算。所以把 fork 元工具也一并计入父 loop 的预算,堵住这个绕行口。这是「护栏要堵在真正 会漏的那条路上」的一个例子。

诚实地说:检索预算是一道补偿弱模型的软护栏,不是万能的。换一个强模型,靠提示词就能收敛, 这道预算基本不会触发。但有它在,弱模型也能跑通,且它和评测体系(M11 的 P1 扣分项)天然挂钩—— 「反复无效检索」本就是要扣分的执行规范问题。

数据飞轮在主 loop 维度闭环#

把记忆的「入口注入」和「出口写回」接上之后,一个完整的小闭环就成了:用户这轮说「不要塑料」→ 收尾时识别成偏好写回 → 下一轮同一用户进来,这条偏好被注入到系统提示里 → Agent 不用用户重复说 就记得。这就是项目里反复强调的「上下文级飞轮」在主链路维度的体现——不靠训练模型,靠沉淀和 注入上下文来让系统越用越懂用户。注入的成本通常只有几百 token(只存结论性偏好),却省掉了 保留几万 token 历史会话的开销,信息密度高得多。

三个值得深讲的机制设计#

收敛的 defense-in-depth:对抗模型不确定性#

⚠️ 口径更正(与当前代码一致):本节原描述「三级」的第 1 级 = 请求模型前摘工具wrap_model_call → _tools_to_drop())。这一级后来为了 M6.1 前缀缓存治理 整个移除了_tools_to_drop 现码已不存在)——摘工具一变就打断 prompt cache 前缀。现在只剩下面的 两级、都在执行层,工具表恒定。「摘工具省解码 vs 破缓存前缀」的取舍详见 Mperf.1(那轮在 over-loop 治理上又重演了一次同样的回退)。

「搜够了 / 预算耗尽就收敛」说起来简单,做起来靠两层执行层机制堆 defense-in-depth:

  1. 工具执行前拦wrap_tool_call → 哨兵消息):模型生成了被禁工具的调用,在执行入口硬拦、回一条 哨兵(工具照常在表里、只是不执行),逼模型下一步转向。这是唯一的硬拦点(不再有「请求前摘工具」那层)。
  2. 工具结果里标注(预算批注):结果尾部附「检索预算已用满 / 本平台已搜 X/N 次」提示,让模型下一步 规划时感知到约束、主动走收尾而非绕行。

为什么不摘工具:摘工具(请求模型前从工具表删掉)能省一轮无效解码,但工具表一变就打断 prompt cache 前缀,与 M6.1 的缓存治理冲突。取舍下来选缓存:用「执行层哨兵拦执行 + 结果批注推收敛」换工具表恒定。 代价是哨兵拦执行不拦 decode——模型仍会为被拒的调用花一次解码,但那比打断缓存前缀便宜。

全树计数为什么不用 ContextVar#

检索预算和 token 成本都需要全树(主 + 所有 fork 子 Agent)共享一个计数器。直觉是用 ContextVar(项目里 thread_id 就用它),但实测发现行不通

  • asyncio.create_task()复制当前上下文给新任务(fork 子 Agent 就是 create_task 出来的)。
  • 子任务里 ContextVar.set() 改的是自己那份副本,不会传播回父。
  • 所以子 Agent 搜了 3 次、累加到 3,父看到的计数还是 0——全树预算形同虚设。

解法:用模块级 dict[str, State],key 是 session_dirsession_dir 在 fork 时刻意继承父的(通过 thread_scope),所以主和所有子 Agent 往同一个 key 累加。这也是 token 成本预算(ENH-F)用同样模式的原因——它们遇到的是同一个 ContextVar 不传播的问题。

item_picker 出口的定点收尾注入#

「口头收尾」是弱模型的顽固方差缺陷:走完 item_picker 精选后,模型可能输出「好的,我来生成清单」就停下,而不是真的调 shopping_summary 工具。system prompt 里的全局纪律治不死它——同一份 prompt 这次调、那次不调。

解法在中间件的 item_picker 出口:检测到 item_picker 返回了精选结果,就在工具结果尾部定点注入「你的下一个动作必须是真的调用 shopping_summary 工具」。这比 system prompt 开头的全局约束有效得多——因为它出现在模型正要决定下一步的那一刻,注意力权重最高。配合 M11 评测验证:这一改让「口头收尾」的评分从 10 → 93.3(+83 分)。


后续演进(M9 之后落地的增强)#

主 loop 合龙后,又在不改变 AgentLoop 范式的前提下做了几项增强:

  • 非购物快捷路径:system prompt 里新增了对品类行情查询(category_insight → chat_fallback)和外部事实查询(web_search → chat_fallback)的短路径。Think 第一步先判断意图类型——品类行情、外部事实、闲聊都不进入完整购物流程,工具调完后直接 chat_fallback 收尾。减少了一类 bad case:用户只是问「耳机什么价位算贵」,Agent 却启动跨平台检索 + 比价全流程。
  • web_search 门控放宽:从「仅 item_search 召回为空时放行」改为两种合法场景:① 独立知识查询(还没进入购物检索流程)放行,让 prompt 引导正确使用;② 购物流程空召回兜底放行。购物流程中已有候选时仍拦(不是「找更好」的渠道)。这修掉了一个实际体验问题——用户问品牌口碑等外部事实时,web_search 被「还没搜过商品 → 拦」的旧逻辑误杀。
  • 全树 token 用量下发:每轮任务结束时,把全树(主 + 各 fork 子 Agent)的 token 用量(input / output / total / cost_usd)随 task_result 事件下发 + 随 turns.json 落盘,前端在该轮右下角与「用时」并排显示,hover 看拆分。与 ENH-F 的 FinOps 预算闸共用记账基础设施。
  • 工具思考结果摘要:每件工具在 report_tool_end 时附一段人读摘要(result 字段),前端展开看「这一步查到了什么」而非元信息。
  • item_search 多维 filter:支持 price_usd_max / min_rating / brand_exclude,在 Qdrant 召回阶段直接过滤,减少浪费。

可能的面试追问#

  • 为什么不用硬编码的工作流(先 A 再 B 再 C)而坚持 AgentLoop? 购物意图千变万化——闲聊、 澄清、单平台、跨平台、要不要查外部评测,写死的流程覆盖不全。AgentLoop 让模型按需编排,标准 流程只是「强烈建议的次序」,不是状态机。代价是要额外防不收尾,这正是四重护栏在做的事。
  • 检索预算会不会误伤正常的多轮检索? 软上限给得比健康流程的实际用量高不少,且它只是「提示」 不是「禁止」——真有必要还能再搜,只是被强烈推着收尾。宁可偶尔早收一点,也不要死循环。
  • artifact 这套和直接让工具自己写库,哪个好? 让工具自己写库会把工具层和记忆层耦死,而且工具 在执行时拿不到「当前是哪个登录用户」这种请求级身份。把结构化结果交回主流程、由主流程在出口 统一写回,分层更干净,身份也齐全。
  • 同质 fork 的子 Agent 也能再 fork,会不会爆? 深度有硬上限(到顶直接拒绝并转成一句普通的 工具错误返回,不让 Agent 崩),加上每个子任务自己的超时和迭代上限,递归是收敛的。
plaintext