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 硬停 |
| 6 | research 是函数不是子 Agent | 固定三步无反馈环,次数上界 = len(targets) ≤3 |
| 7 | deadline 只收紧不放宽 | 出站点取 min(自身超时, 本轮剩余),见底给 50ms |
30 秒版#
主循环用 AgentScope 的 Agent + ReActConfig,模型每轮自选工具,调到 6 个终结工具之一就停。
Agent 最常见的失败是不收尾,所以我在框架外按「从软到硬」加了四道:终结置位后硬拦、纯文字收尾
重发一次、看门狗 45 秒软催 30 秒硬停、迭代上限 30 与整轮超时 300 秒最后截住。另一半功夫是别跑多余
的轮:planner 开局预置、检索后自动比价精挑,一条普通购物轮只剩 4 次模型调用(含 planner 预置那次)。
3 分钟版#
- 一轮的骨架:
run_agent起asyncio.timeout(300)并写 deadline,适配器HarnessAgentAdapter挂在on_model_call/on_reasoning/on_reply三个点上,模型每次迭代都从这里过。 - 开局不让模型做确定的事:
prefill()在第 1 次模型调用之前跑掉 planner(有图先image_understand),同时并发预取category_insight,模型第 1 轮拿着 plan + 品类行情直接检索。 - 检索后也不让模型搬运:
item_search成功即武装,下一次pre_think自动跑price_compare+item_picker,结果以 hint 注入,模型下一步就能收尾(autopick.py)。 - 终结:调
TERMINAL_TOOLS里任一个,mark_terminal置terminal_reached并记terminal_step;之后terminal_reached_gate拦掉一切工具调用,只放同一批次里的兄弟终结调用。 - 终结工具的产出就是最终答案:
shopping_summary走终结直出不再唤模型;chat_fallback/ask_user(closes_turn)的正文由_merge_terminal_body并进最终消息,模型补的复述丢掉。 - 停不下来的三级处置:
terminal_enforcer写retry_nudge→ 适配器吞ReplyEndEvent再来一轮 (每次模型调用最多 1 次);liveness_watchdog45 秒软催、再 30 秒合成部分答案硬停;最后是MAIN_MAX_ITERS=30与MAIN_AGENT_TIMEOUT_SEC=300。 - 别让它跑贵:工具结果截断 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_safegather 并发,谁先跑完 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回滚。
机制怎么跑#
普通购物轮(无图、单槽位)的完整序列,可背:
orchestrator.run_agent→_run_turn:set_deadline(300)+asyncio.timeout(300)(app/agent/orchestrator.py:454-459)。HarnessAgentAdapter.on_reply先跑_prefill→prefill():有图先image_understand,再planner.ainvoke,走与真调一次完全相同的post_tool_call管线;同时并发_prefetch_kb取category_insight(quick)(条件:KB_PREFETCH=1+ planner 判出品类 + 本轮有购物类任务)。 第 1 次模型调用在此之后。- 每次迭代进
on_model_call(adapter.py:147):① 终结直出(本轮调过shopping_summary就直接 返回清单,不唤模型);②think_step += 1、terminal_nudge_retries归零;③maybe_autopick; ④harness.run("pre_think")(liveness_watchdog5、budget_router20);⑤fallback_answer有值就置terminal_reached直接返回;⑥ 按档位换模型后调next_handler,流式记账。 - 第 2 次模型调用吐
item_search(跨平台 / 多槽位就同轮多发几条,框架并发)。工具走pre_tool_call(terminal_reached_gate5 …tool_breaker_gate48)→ 执行 →post_tool_call(truncate_result10、result_nudges20、mark_terminal30 …)。 after_tool_success刷新guard.last_progress_at(看门狗的「实质进展」就是它)并武装 autopick。- 下一次
pre_think里maybe_autopick自动跑price_compare+item_picker,结果以 hint 注入 (autopick.py);套装轮(≥2 槽)不自动,AUTOPICK=0关。 - 第 3 次模型调用吐
shopping_summary(文案与逐件理由写在入参里,工具只排版)。mark_terminal置terminal_reached+terminal_step。 - 下一次迭代进
on_model_call时被终结直出拦下(adapter.py:161),不真的发给模型,清单原样返回。 on_reply收到ReplyEndEvent:有retry_nudge就_force_another_round,否则_merge_terminal_body并正文 →_finalize(output_guard/output_audit)。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_ITERS | 30(MAIN_AGENT_MAX_ITERATIONS) | app/agent/limits.py |
MAIN_AGENT_TIMEOUT_SEC | 300 | app/agent/limits.py |
MAX_TERMINAL_NUDGE_RETRIES | 1(每次模型调用归零) | app/harness/budgets.py:44 |
WATCHDOG_STALL_SEC / WATCHDOG_GRACE_SEC | 45 / 30 | app/harness/hooks/termination.py:110 |
MAX_TOOL_RESULT_TOKENS | 4000 | app/harness/truncation.py:15 |
LoopDetector(window, threshold) | 6 / 4 | app/harness/loop_detector.py:25 |
RETRIEVAL_BUDGET_CAP | 8(env RETRIEVAL_BUDGET) | app/harness/budgets.py:22 |
WEB_SEARCH_TASK_QUOTA / RESEARCH_SEARCH_QUOTA | 2 / 6 | app/harness/retrieval_budget.py:31,50 |
| 工具熔断阈值 / 恢复窗口 | 3 / 60s | app/harness/hooks/repetition.py |
失控防线:5 层#
| 层 | 落点 | 触发后怎么办 |
|---|---|---|
| ① 迭代上限 | MAIN_MAX_ITERS=30 | 当错误上报,不抛异常,中间结果照常收尾 |
| ② 整轮超时 + deadline | MAIN_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-14 | 12e42a4(悬空提交) | 撤阶段白名单禁令,阶段机降为遥测,效率约束改预算制 | 白名单在 planner 误判时把正确的 item_search 锁死,模型每轮重试(旧版核对过) |
| 2026-07 | eb9d61b(悬空提交) | 第二轮延迟减法:83.2s → 39.7s,主 loop 9 → 5 轮 | 收尾后再唤模型复述属纯搬运(旧版核对过) |
| 2026-09-10 | 8eaf826 | 深度闸 / 工具白名单由 raise 降为报警(301 会话 0 触发) | 「为什么调不到」不该有两个答案(旧版核对过) |
| 2026-09-15 | b2b7bd5、7945ea2 | 开局预取 KB + 检索后自动比价精挑 | 固定 5 轮里两轮模型只搬运(旧版核对过) |
| 2026-09-16 | 94f6911 | 终结硬停闸加 terminal_step,同批兄弟调用放行 | 布尔位在 gather 并发下切开一次决策,同轮双 create_order 只落一张卡 |
| 2026-09-16 | 95103a1 / 91258f3 / ae56aa6 / 2f2b304 | research 有界函数 + 独立配额 6 条 + prompt 事前告知 + 空结果必带 note | 研究类用法要事前可知的上界;与 web_search 共用额度会互相饿死 |
| 2026-09-16 | ddaf043 | chat_fallback 原样透出 message + _merge_terminal_body | 答案被吞:61 字节 → 1404 字节 |
| 2026-09-16 | 7d05fb2 | 新增终结工具 present_comparison(模型调工具 / 前端按钮走 REST 两条入口) | 它产出的就是最终结构化答案,再调 shopping_summary 是散文重讲 |
| 2026-09-19 | fc855be | ask_user 加 closes_turn,终结判据升级为「工具名 + 入参」 | 不为 present_suggestions 形态新增第三个问用户的工具 |
| 2026-09-19 | 3943ecf | 新增终结工具 present_guide(纯文字讲标准那一轮) | 同 present_comparison:调完再 chat_fallback 复述是 over-loop 老形态 |
| 2026-09-19 | 8b851f4 | 删 skill 预注入,改模型自觉 + 和本轮第一个检索工具同轮发出 | 机制管事实接地、模型自觉管打法加载;预注入会让同一份正文在多轮里躺 N 份 |
| 2026-09-21 | 383ba8b | deadline 下传,各出站点 clamp_timeout | 出站点对外层超时一无所知,注定等不到的结果还在等 |
| 2026-09-21 | 10526d1 | 删四阶段状态机(phase_machine.py,165 行) | 阶段只是 called_tools / 候选数 / picks 数的影子,影子要自己维护不变量就成了第二套真相 |
数字与证据#
| 数字 | 指什么 | 来源 | 状态 |
|---|---|---|---|
| 6 | TERMINAL_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 → 100 | q19 换品类死锁场景 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 的预算。
坑与易混点#
CLAUDE.md与本仓代码对终结工具的口径:CLAUDE.md 第 5 节写TERMINAL_TOOLS是 5 个(缺present_guide),代码 HEAD 是 6 个 +ask_user(closes_turn=True)。以代码为准。limits.py里叫MAIN_MAX_ITERS,env 键却是MAIN_AGENT_MAX_ITERATIONS,别搜错。RETRIEVAL_BUDGET是 env 键名,代码里的常量叫RETRIEVAL_BUDGET_CAP(budgets.py:22)。- 旧版手册说「失控防线三层」,那是把迭代上限与超时并成一层、且 deadline 还没有;现在按落点数是 5 层。
query_order不是终结工具,create_order/cancel_order是——交易轮调完就该停下等用户表态。
本章和别章的接口#
- 写边界(
PermissionEngine、确认卡 HTTP 决议)与单环收敛的实测证据在第 3 章。 - skill 怎么加载、
load_skill与同轮发出的措辞在第 14 章。 - Harness 的 hook 点全表、
run_state与错误分级在第 6 章;压缩与前缀缓存在第 7 章。