面试知识库

2. AgentLoop 主循环与终止安全#

一句话结论:模型自己选工具、调到终结工具才停;停不下来的路由机制堵,不靠提示词。

速背卡#

#hint(≤15 字)展开一句
1终结工具 6 个 + 1 个看入参TERMINAL_TOOLS 6 个;ask_user(closes_turn=True) 按入参算终结
2普通轮 4 次模型调用(含预置)planner 预置 → item_search → 自动比价精挑 → shopping_summary
3失控防线 5 层迭代 30 / 超时 300s / 结果截断 / 循环检测 / 三本检索账
4终结工具产出=最终答案模型补的那句复述丢掉,工具正文并进 final_text(ddaf043)
5停不下来先软后硬催 1 次 → 看门狗 45s 软 + 30s 硬 → max_iters 硬停
6research 是函数不是子 Agent固定三步无反馈环,次数上界 = len(targets) ≤3
7deadline 只收紧不放宽出站点取 min(自身超时, 本轮剩余),见底给 50ms

30 秒版#

主循环用 AgentScope 的 Agent + ReActConfig,模型每轮自选工具,调到 6 个终结工具之一就停。 Agent 最常见的失败是不收尾,所以我在框架外按「从软到硬」加了四道:终结置位后硬拦、纯文字收尾 重发一次、看门狗 45 秒软催 30 秒硬停、迭代上限 30 与整轮超时 300 秒最后截住。另一半功夫是别跑多余 的轮:planner 开局预置、检索后自动比价精挑,一条普通购物轮只剩 4 次模型调用(含 planner 预置那次)。

3 分钟版#

  1. 一轮的骨架:run_agent 起 asyncio.timeout(300) 并写 deadline,适配器 HarnessAgentAdapter 挂在 on_model_call / on_reasoning / on_reply 三个点上,模型每次迭代都从这里过。
  2. 开局不让模型做确定的事:prefill() 在第 1 次模型调用之前跑掉 planner(有图先 image_understand),同时并发预取 category_insight,模型第 1 轮拿着 plan + 品类行情直接检索。
  3. 检索后也不让模型搬运:item_search 成功即武装,下一次 pre_think 自动跑 price_compare + item_picker,结果以 hint 注入,模型下一步就能收尾(autopick.py)。
  4. 终结:调 TERMINAL_TOOLS 里任一个,mark_terminal 置 terminal_reached 并记 terminal_step;之后 terminal_reached_gate 拦掉一切工具调用,只放同一批次里的兄弟终结调用。
  5. 终结工具的产出就是最终答案:shopping_summary 走终结直出不再唤模型;chat_fallback / ask_user(closes_turn) 的正文由 _merge_terminal_body 并进最终消息,模型补的复述丢掉。
  6. 停不下来的三级处置:terminal_enforcer 写 retry_nudge → 适配器吞 ReplyEndEvent 再来一轮 (每次模型调用最多 1 次);liveness_watchdog 45 秒软催、再 30 秒合成部分答案硬停;最后是 MAIN_MAX_ITERS=30 与 MAIN_AGENT_TIMEOUT_SEC=300。
  7. 别让它跑贵:工具结果截断 4000 token、web_search 单条 1500 / 整批 15000 字符 + <external_content> 围栏、LoopDetector(6, 4) 打转提示、三本互不透支的检索账。

它解决什么问题#

① 模型说「现在给你生成清单」然后停了

  • 坏法:当成正常结束。用户屏幕上只有一句客套话,产物里也是——看不见,因为任务状态是成功。
  • 修法:terminal_enforcer(post_reflect, 60)发现本轮 called_tools 与 TERMINAL_TOOLS 无交集就写 retry_nudge,adapter._force_another_round 把提示追加进 agent.state.context 后吞掉 ReplyEndEvent,框架原生语义就是再来一轮。
  • 代价:依赖框架「吞结束事件即继续」这一行为;纠正提示留在上下文里。

② 催收尾会不会催成死循环

  • 坏法:整个 loop 只给一次配额 —— 第 3 轮催过,第 12 轮就没得催了。
  • 修法:on_model_call 每次把 terminal_nudge_retries 归零,MAX_TERMINAL_NUDGE_RETRIES=1 (harness/budgets.py:44);cur_iter >= max_iters 时 _force_another_round 不吞事件。
  • 代价:模型持续不听时每次调用都多一轮,靠迭代上限封顶。

③ 同轮两个 create_order 只落一张卡(94f6911)

  • 坏法:mark_terminal 置布尔位、check_terminal_reached 读它。两个调用被框架按 is_concurrency_safe gather 并发,谁先跑完 post 就把兄弟拦死,出几张卡取决于事件循环调度。
  • 修法:加 GuardState.terminal_step 记置位时的 think_step(同一条 AI 消息的并行调用共享它, 等于批次 id),闸改判「同批 + 本次也是终结调用」才放行(termination.py:53)。
  • 代价:判据从一个布尔位变成两维;同批非终结工具、下一个 think_step 的终结工具照拦(防连环下单)。

④ chat_fallback 把答案吞了:61 字节 → 1404 字节(ddaf043)

  • 坏法:工具拿 fast 模型把模型写好的 message 「归纳成一两句」,再加上框架在终结工具后还会让模型 补一句「以上就是…」,final_text 取的正是那句。1500+ 字符的选购指南写到文件后只剩 312 字节。
  • 坏了看不看得见:看不见——主 loop 的 TextBlockDeltaEvent 不灌 summary_delta,前端 task_result 又覆盖流式文本,屏幕、产物、会话历史三处存的都是那句客套话。
  • 修法:chat_fallback 原样透出 message(空才退回内部 LLM);adapter._merge_terminal_body 把终结工具产出与同条消息里的正文并回最终答案,口径统一成「终结工具的产出就是最终答案」。
  • 代价:「模型尾巴比工具产出还长就不动」是按长度判的启发式,方向是宁可多带不丢。

⑤ 研究类需求要不要派个子 Agent

  • 坏法:给它一个能自己决定搜几次的子循环 —— 次数事前不可知,配额扣不动,正文还要进主环上下文。
  • 修法:research(targets, aspects) 做成有界函数:模板展开 → 并行搜 → 一次 fast 模型归纳成 schema,固定三步无反馈环,调用次数上界 = len(targets),RESEARCH_MAX_TARGETS=3 截断 (app/tools/research.py:50)。原始结果落 session_dir/research_<n>.json,不进上下文。
  • 代价:不能追问、不能改口;claim 是归纳模型转述的外部文本,注入面是收窄不是消除(所以 research 进 EXTERNAL_SOURCE_TOOLS)。

⑥ 外层超时是从外面一刀砍的(383ba8b)

  • 坏法:只有 asyncio.timeout(300)。出站点对它一无所知,「主 loop 只剩 3 秒、这次检索照样等 5 秒」。
  • 修法:run_agent 入口 set_deadline(MAIN_AGENT_TIMEOUT_SEC)(ContextVar,monotonic 刻度),各出站 点用 clamp_timeout(base) 取 min(自身超时, 剩余);LLM 网关、Qdrant、OpenSearch、reranker、 embedding、web_search 都接了。
  • 代价:clamp_timeout 只收紧不放宽;剩余见底给 50ms 而不是 0(0 在 httpx 是无限等、在 Qdrant 是参数 错误)。DEADLINE_ENABLED=0 回滚。

机制怎么跑#

普通购物轮(无图、单槽位)的完整序列,可背:

  1. orchestrator.run_agent → _run_turn:set_deadline(300) + asyncio.timeout(300) (app/agent/orchestrator.py:454-459)。
  2. HarnessAgentAdapter.on_reply 先跑 _prefill → prefill():有图先 image_understand,再 planner.ainvoke,走与真调一次完全相同的 post_tool_call 管线;同时并发 _prefetch_kb 取 category_insight(quick)(条件:KB_PREFETCH=1 + planner 判出品类 + 本轮有购物类任务)。 第 1 次模型调用在此之后。
  3. 每次迭代进 on_model_call(adapter.py:147):① 终结直出(本轮调过 shopping_summary 就直接 返回清单,不唤模型);② think_step += 1、terminal_nudge_retries 归零;③ maybe_autopick; ④ harness.run("pre_think")(liveness_watchdog 5、budget_router 20);⑤ fallback_answer 有值就置 terminal_reached 直接返回;⑥ 按档位换模型后调 next_handler,流式记账。
  4. 第 2 次模型调用吐 item_search(跨平台 / 多槽位就同轮多发几条,框架并发)。工具走 pre_tool_call(terminal_reached_gate 5 … tool_breaker_gate 48)→ 执行 → post_tool_call(truncate_result 10、result_nudges 20、mark_terminal 30 …)。
  5. after_tool_success 刷新 guard.last_progress_at(看门狗的「实质进展」就是它)并武装 autopick。
  6. 下一次 pre_think 里 maybe_autopick 自动跑 price_compare + item_picker,结果以 hint 注入 (autopick.py);套装轮(≥2 槽)不自动,AUTOPICK=0 关。
  7. 第 3 次模型调用吐 shopping_summary(文案与逐件理由写在入参里,工具只排版)。 mark_terminal 置 terminal_reached + terminal_step。
  8. 下一次迭代进 on_model_call 时被终结直出拦下(adapter.py:161),不真的发给模型,清单原样返回。
  9. on_reply 收到 ReplyEndEvent:有 retry_nudge 就 _force_another_round,否则 _merge_terminal_body 并正文 → _finalize(output_guard / output_audit)。
  10. pump_events 读 finished_reason:EXCEED_MAX_ITERS → report_error("max_iters"), ERROR → report_error("reply_error"),都不抛异常,中间结果照常收尾(app/agent/events.py:54-63)。

终结工具(app/agent/constants.py,HEAD 核实)#

shopping_summary / chat_fallback / create_order / cancel_order / present_comparison / present_guide —— 6 个,调用即终结。第 7 种是 ask_user(closes_turn=True):全仓唯一一个终不终结 取决于入参的工具,由 is_terminal_call(name, args) 判,入参拿不到时退回名字判据(按非终结走)。 query_order 刻意不在其中(查完往往还要取消或再买一件)。

终止相关常量#

常量默认位置
MAIN_MAX_ITERS30(MAIN_AGENT_MAX_ITERATIONS)app/agent/limits.py
MAIN_AGENT_TIMEOUT_SEC300app/agent/limits.py
MAX_TERMINAL_NUDGE_RETRIES1(每次模型调用归零)app/harness/budgets.py:44
WATCHDOG_STALL_SEC / WATCHDOG_GRACE_SEC45 / 30app/harness/hooks/termination.py:110
MAX_TOOL_RESULT_TOKENS4000app/harness/truncation.py:15
LoopDetector(window, threshold)6 / 4app/harness/loop_detector.py:25
RETRIEVAL_BUDGET_CAP8(env RETRIEVAL_BUDGET)app/harness/budgets.py:22
WEB_SEARCH_TASK_QUOTA / RESEARCH_SEARCH_QUOTA2 / 6app/harness/retrieval_budget.py:31,50
工具熔断阈值 / 恢复窗口3 / 60sapp/harness/hooks/repetition.py

失控防线:5 层#

层落点触发后怎么办
① 迭代上限MAIN_MAX_ITERS=30当错误上报,不抛异常,中间结果照常收尾
② 整轮超时 + deadlineMAIN_AGENT_TIMEOUT_SEC=300 + clamp_timeout外层抛 TimeoutError;出站点提前放弃
③ 工具结果截断truncate_tool_result(4000);web_search 1500 / 15000 字符 + <external_content>截断文本 + 「可用更窄的查询参数重试」
④ 循环检测 + 熔断LoopDetector(6, 4)、tool_breaker_gate(48)结果尾部追加 [系统提示],不硬停
⑤ 三本检索账RETRIEVAL_BUDGET=8 / WEB_SEARCH_TASK_QUOTA=2 / RESEARCH_SEARCH_QUOTA=6互不透支;耗尽回 [error] 并报余额

演进时间线#

日期提交改了什么为什么
2026-07-1412e42a4(悬空提交)撤阶段白名单禁令,阶段机降为遥测,效率约束改预算制白名单在 planner 误判时把正确的 item_search 锁死,模型每轮重试(旧版核对过)
2026-07eb9d61b(悬空提交)第二轮延迟减法:83.2s → 39.7s,主 loop 9 → 5 轮收尾后再唤模型复述属纯搬运(旧版核对过)
2026-09-108eaf826深度闸 / 工具白名单由 raise 降为报警(301 会话 0 触发)「为什么调不到」不该有两个答案(旧版核对过)
2026-09-15b2b7bd5、7945ea2开局预取 KB + 检索后自动比价精挑固定 5 轮里两轮模型只搬运(旧版核对过)
2026-09-1694f6911终结硬停闸加 terminal_step,同批兄弟调用放行布尔位在 gather 并发下切开一次决策,同轮双 create_order 只落一张卡
2026-09-1695103a1 / 91258f3 / ae56aa6 / 2f2b304research 有界函数 + 独立配额 6 条 + prompt 事前告知 + 空结果必带 note研究类用法要事前可知的上界;与 web_search 共用额度会互相饿死
2026-09-16ddaf043chat_fallback 原样透出 message + _merge_terminal_body答案被吞:61 字节 → 1404 字节
2026-09-167d05fb2新增终结工具 present_comparison(模型调工具 / 前端按钮走 REST 两条入口)它产出的就是最终结构化答案,再调 shopping_summary 是散文重讲
2026-09-19fc855beask_user 加 closes_turn,终结判据升级为「工具名 + 入参」不为 present_suggestions 形态新增第三个问用户的工具
2026-09-193943ecf新增终结工具 present_guide(纯文字讲标准那一轮)同 present_comparison:调完再 chat_fallback 复述是 over-loop 老形态
2026-09-198b851f4删 skill 预注入,改模型自觉 + 和本轮第一个检索工具同轮发出机制管事实接地、模型自觉管打法加载;预注入会让同一份正文在多轮里躺 N 份
2026-09-21383ba8bdeadline 下传,各出站点 clamp_timeout出站点对外层超时一无所知,注定等不到的结果还在等
2026-09-2110526d1删四阶段状态机(phase_machine.py,165 行)阶段只是 called_tools / 候选数 / picks 数的影子,影子要自己维护不变量就成了第二套真相

数字与证据#

数字指什么来源状态
6TERMINAL_TOOLS 个数(另加 ask_user(closes_turn=True))app/agent/constants.py✓ HEAD 核实
30 / 300s主 loop 迭代上限 / 整轮墙钟上限app/agent/limits.py✓ HEAD 核实
45 / 30s看门狗软催 / 硬停宽限hooks/termination.py:110✓ HEAD 核实
8 / 2 / 6检索总量 / web_search 任务配额 / research 搜索配额budgets.py:22、retrieval_budget.py:31,50✓ HEAD 核实
61 → 1404 字节chat_fallback 修前后同一条 query 的最终答案提交 ddaf043 正文✓ 提交正文
9 → 4 次 / 28.3 → 17.5s通勤背包 query 模型调用中位数 / 墙钟中位数docs/plans/baseline-artifacts/latency_round3_*.json旧版核对过
78,963 → 22,483同 query 输入 token 中位数同上旧版核对过
27 轮阶段白名单事故用户手动取消前的轮数docs/milestones/M12.1-…md仅口径
76.7 → 100q19 换品类死锁场景 Rubric提交 12e42a4 正文(悬空提交)旧版核对过
3 次模型调用上面第 3~7 步实际发给模型的次数(planner 预置另算 1 次)本章机制节自行推导仅口径

追问 10 题#

Q1. Agent 最常见的失败是什么,你怎么处理? 不收尾。四道由软到硬:终结置位后硬拦(mark_terminal + terminal_reached_gate)、纯文字收尾重发 (terminal_enforcer + _force_another_round)、看门狗、max_iters 30 与超时 300s。

Q2. 模型说「我这就生成清单」然后停了,框架里发生什么? on_reasoning 后跑 post_reflect,terminal_enforcer 发现本轮没有 tool_call 且 called_tools 与 TERMINAL_TOOLS 无交集 → 写 retry_nudge;on_reply 追加提示进上下文并吞掉 ReplyEndEvent。

Q3. ⚠ 为什么「本轮调过哪些工具」只认 called_tools,不扫 messages? messages 含续聊恢复的历史。上一轮调过 shopping_summary,这一轮空口收尾也会被判成调过。 called_tools 每轮新建(adapter.py 终结直出那段注释里踩过这个坑)。

Q4. ⚠ 同轮发两个 create_order 会怎样? 两个都放行,落两张确认卡。判据不是布尔位而是 guard.terminal_step == guard.think_step(同批) 且本次也是终结调用;下一个 think_step 再调终结工具照拦,防连环下单(94f6911)。

Q5. 终结工具调完,用户看到的答案是谁写的? 终结工具的产出。shopping_summary 走终结直出直接返回清单;chat_fallback / present_guide / ask_user(closes_turn) 的正文由 _merge_terminal_body 并进最终消息,模型补的那句复述丢掉。

Q6. ⚠ research 为什么是工具函数不是子 Agent? 它要有事前可知的上界,配额才扣得动:固定三步无反馈环,搜索条数 = len(targets) ≤3, RESEARCH_SEARCH_QUOTA=6 ≈ 两次满载调用。子 Agent 的次数事前不可知,且正文会进上下文。

Q7. research 为什么不共用 web_search 的额度? research 内部直调 search_web,单次就发 3 搜,是 WEB_SEARCH_TASK_QUOTA=2 的 1.5 倍,共用等于 两边互相饿死。也不进 RETRIEVAL_TOOLS——它不产可下单候选,混进总额只会挤掉 item_search(91258f3)。

Q8. ⚠ deadline 和外层 asyncio.timeout 是不是重复? 不是。timeout 从外面一刀砍,出站点看不见;deadline 把同一个数下传,出站点用 clamp_timeout 取 min(自身超时, 剩余)。clamp_timeout 只收紧不放宽,剩余见底给 50ms 不给 0。

Q9. 看门狗怎么判「没有进展」?硬停给用户什么? 任一工具调用成功即算进展(after_tool_success 刷新 guard.last_progress_at,自动比价精挑走的 也是这个函数)。停滞 45s 注入收敛指令,再 30s 用 _partial_answer() 按候选数合成回答硬停。

Q10. 压力题:步骤大多固定,为什么不写成硬状态机? 试过两次都退回来了:阶段白名单在 planner 误判时把正确工具锁死(12e42a4);四阶段状态机是 called_tools / 候选数 / picks 数的影子,维护不变量就成了第二套真相,2026-09-21 整套删掉(10526d1)。 确定的步骤直接由机制执行(prefill / autopick),不确定的交模型,约束用永不为 0 的预算。

坑与易混点#

  1. CLAUDE.md 与本仓代码对终结工具的口径:CLAUDE.md 第 5 节写 TERMINAL_TOOLS 是 5 个(缺 present_guide),代码 HEAD 是 6 个 + ask_user(closes_turn=True)。以代码为准。
  2. limits.py 里叫 MAIN_MAX_ITERS,env 键却是 MAIN_AGENT_MAX_ITERATIONS,别搜错。
  3. RETRIEVAL_BUDGET 是 env 键名,代码里的常量叫 RETRIEVAL_BUDGET_CAP(budgets.py:22)。
  4. 旧版手册说「失控防线三层」,那是把迭代上限与超时并成一层、且 deadline 还没有;现在按落点数是 5 层。
  5. query_order 不是终结工具,create_order / cancel_order 是——交易轮调完就该停下等用户表态。

本章和别章的接口#

  • 写边界(PermissionEngine、确认卡 HTTP 决议)与单环收敛的实测证据在第 3 章。
  • skill 怎么加载、load_skill 与同轮发出的措辞在第 14 章。
  • Harness 的 hook 点全表、run_state 与错误分级在第 6 章;压缩与前缀缓存在第 7 章。