English version: From chat to agent: tool loops and collapse breakers。“LLM 应用工程”系列第 4 篇。本系列以一个生产级 AI 写作 agent(FastAPI + React + Tauri,351 个测试用例)的实现与实测数据为实证基座。
问题:把循环交给模型之后
前三篇讨论的都是“单次调用”的工程:控制输入、组装提示词、清洗输出。agent 的分水岭在于把下一步做什么也交给模型:给它一组工具,执行它的调用,把结果喂回消息列表,再调一次——直到任务完成。
本文的具体场景:给 agent 一个数据文件夹,文件名与内容事先未知,要它自己读文件、判断这是什么实验、写出报告。天真实现就是一个 while True 工具循环,它在生产环境会以三种方式失败:
- **路径不可信。**读哪个文件由模型说了算,一个含
../的参数就能读出数据目录之外的东西。 - 没有自然终点。“探索到什么程度算够”由模型判断,循环次数没有上界,成本也没有。
- **循环坍塌。**最隐蔽的一类:长上下文下,模型退化为以相同参数重复调用同一工具。公开案例 qwen-code issue #4695 记录了单次会话 43 次连续
git status、消耗 8.9M token;我们所用的 deepseek-v4-pro 档位在长上下文下也易坍塌成同一模式。SDK 的重试机制对此无效——重试只管网络与限流,而这类调用在 API 层每一次都是“成功”的,必须客户端熔断。
下文按构建顺序拆解五个机制。实现基于 OpenAI 兼容的标准工具调用(默认 DeepSeek,任何兼容端点可替换);入口 run_agent 是生成器,逐事件 yield 供 SSE 流式展示。
机制一:最小工具集与路径护栏
探索阶段共五个工具:list_dir、read_file、read_image(本地视觉模型读图取数)、make_figure(执行模型写的 matplotlib 代码出图)、finish_exploration。三个设计点:
分段续读而不是一次塞满。read_file 把文本截断在 READ_CHARS = 7000 字符,截断时在结尾附一句“已截断,可用 offset/limit 按行续读”。截断不是错误,是给模型的决策点:还有更多,值不值得继续读由它权衡。这也给后面的熔断指纹立了一条约束——不同 offset 的续读是合法行为,不能误伤。
阶段转换显式化。finish_exploration 把“探索结束”从隐式信号(模型不再调工具)变成一次显式调用:schema 强制 experiment / goal / key_findings 必填,另有作业要求、方法思路、范本报告三个可选槽位。阶段转换有结构化记录,也给熔断留了一个明确的收尾出口。
每个路径入口过 _safe。
def _safe(rel, root=None):
root = root or base_docs_root()
if not root:
return None, None
t = os.path.normpath(os.path.join(root, rel or ""))
if not (t == root or t.startswith(root + os.sep)):
return None, None
return root, t
normpath 拼接后必须仍在根目录内,越界返回 (None, None),工具层把它变成一句“路径不存在或越界”作为工具结果回给模型——模型能看到并自行纠正,主流程不抛异常。make_figure 的工具描述里另写明沙箱铁律(禁止 import os/sys/subprocess 等、禁止 open()/eval/exec、禁止双下划线属性),其两层沙箱实现是下一篇的主题。
机制二:探索之前,先零 LLM 扫一遍
模型开始探索前,scan_folder 先做一次确定性递归扫描:按扩展名把文件粗分为文档 / 图片 / 数据 / 其他四桶,返回分桶清单与统计,全程零 LLM、零 token。它一方面给用户一个即时的文件全貌快照(作为首个步骤事件的 detail),另一方面成为后续材料体检的确定性输入。原则与第 11 篇一致:“文件夹里有什么”是代码能回答的问题,模型的探索预算应花在“读哪些、读出什么”上。顺带一句成本设计:探索工具循环跑在便宜档模型上(读文件、决定读什么属于简单任务),撰写与反思才用主模型。
机制三:两道防线——预算上界与坍塌熔断
MAX_TOOL_ITERS = 20 是预算上界,不是防线:坍塌的 agent 会把 20 轮全部烧在重复调用上,产出为零。真正的防线是 _LoopBreaker,核心是一个调用指纹:
def _tool_fingerprint(name, args):
"""工具调用指纹 = 名称 + 全量参数(排序 JSON)。"""
try:
return name + "|" + json.dumps(args, ensure_ascii=False, sort_keys=True)
except Exception:
return name + "|" + str(args)
三个设计约束:全量参数——offset=0 与 offset=100 是两个指纹,合法分段续读不会被误伤(单测钉死这一性质);排序序列化——参数键顺序不影响判定;自身不抛异常——序列化失败回退 str()。finish_exploration 这类收尾工具不参与计数。
阈值分两级。同一指纹第 3 次出现(_LOOP_WARN):注入一条收尾提示——“你在重复调用同一工具+同一参数。已读到的内容足够,请立即调用 finish_exploration 给出结论”——并由 nudge_once 保证每次运行只注入一次,因为注入本身占上下文,反复注入反而加速坍塌。第 5 次(_LOOP_STOP):硬熔断退出探索循环。熔断不是崩溃:yield 一条“检测到重复调用同一工具,已熔断探索,凭已读内容撰写”,带着已积累的素材直接进撰写阶段。降级产出加人在环审阅,好于烧满预算后的零产出。
整个循环外层仍套着 for _ in range(MAX_TOOL_ITERS)——两道防线独立成立。这条防线有端到端测试:mock 客户端在带 tools 的调用里永远返回同一个 read_file(a.txt)(模拟完全坍塌),断言熔断事件必须出现、且 create 调用数 ≤ 6,远小于上限 20。阈值序列(前 2 次 ok、第 3 次 nudge、第 5 次 stop)与指纹性质另有单元测试。
机制四:撰写阶段的反向故障——无工具却吐工具标记
探索结束进入撰写,这一阶段不传 tools。但模型可能把工具调用的内部标记(DeepSeek 的 DSML)当正文吐出来——表面是一次“成功”的生成,落下来的报告是垃圾。_complete_report 用三个确定性判据校验产出:含 \documentclass、不含 DSML、不含 tool_calls。不合格则注入强化指令(“你现在没有任何工具可用……请直接输出完整 LaTeX 源码”)重试,至多 3 次。这是第 3 篇输出清洗思路在 agent 语境的延伸:结构性不合格的输出不去修文本,改约束重试。
机制五:确定性进度——预枚举里程碑与显式 stage
流式 agent 的进度条不能靠猜。这条流水线是“流程即配置”:scan → explore → [figures] → [gap] → draft → reflect×N → bib → [compile],可选步骤由模板开关决定。于是 _plan_pipeline 在开跑前仅凭配置枚举出里程碑序列,M = len(plan),每个里程碑事件都带 index/total,进度条从第一秒起就有确定的分母。两个一致性细节:
- 可选步骤的判定必须与运行时逐字一致:
has_xelatex只探测一次,计划与运行时的 compile 门复用同一结果,二者不可能背离; - stage(阶段名)显式传入而非从
_plan[_pi-1]反查:反思环可能因收敛提前 break,被跳过的里程碑会让位置反查把随后的 bib / compile 误标成 reflect。
已知小偏差如实标注在代码注释里:gap 步运行时还依赖素材是否存在,可能实跑跳过——计划照常计入,结束事件兜底补到 100%。
实测
真实运行验证(devlog 记录,DeepSeek v4-pro,电磁学扫场实验数据):agent 自动 list_dir 并多轮 read_file(PDF、band.csv、扫描参数文件等),自主判断实验类型正确;草稿 16773 字,1 轮反思修订后终稿 20742 字 / 9 节,全程 745 秒。该验证跑在只有 list_dir / read_file / finish_exploration 三个工具的初版上,read_image 与 make_figure 为后续加入。
| 机制 | 替代做法 | 效果 |
|---|---|---|
_safe 路径护栏 | 信任模型给的路径 | 越界变成模型可自行纠正的工具级错误,不抛异常 |
scan_folder 预扫描 | 让模型自己摸清全貌 | 零 token 得到全量文件分桶快照 |
MAX_TOOL_ITERS = 20 | 循环无上界 | 成本硬上界 |
_LoopBreaker | 只靠迭代上限 / SDK 重试 | 坍塌场景 create 调用 ≤ 6(端到端断言),省下 14+ 轮无效调用 |
| DSML 检测重试 | 直接接受“成功”输出 | 至多 3 次重试内拿到合法 LaTeX |
_plan_pipeline + 显式 stage | 按事件数猜进度 | 分母开跑前已知;提前收敛不错标阶段 |
适用边界
- **精确指纹只抓完全坍塌,不抓“游荡”。**同一工具换着参数做低价值调用(把无关文件挨个读一遍)不会触发熔断,那一类要靠预算上界与提示词质量兜底。把指纹做模糊(忽略部分参数)能扩大覆盖,但会误伤合法续读——我们选择窄而准。
- **nudge 有效的前提是模型还听指令。**收尾提示对轻度绕圈有效;上下文已重度坍塌时模型对注入无响应,只有硬熔断有意义——这正是双阈值分级的理由。
- **降级产出需要人在环。**熔断后凭部分素材撰写的报告,价值依赖下游有人审阅。若 agent 的产出会被自动执行、部分信息可能造成危害,熔断策略应改为整单失败而非降级。
- 预枚举进度依赖固定流程。
_plan_pipeline成立的前提是流水线结构在开跑前已定;计划由模型动态生成的开放式 agent 没有先验里程碑序列,进度只能退化为事件计数。 - **阈值不是普适常数。**20 / 3 / 5 按“读文件夹写报告”的任务规模调定,未在其他任务形态上验证。
参考
- qwen-code issue #4695——工具循环坍塌的公开案例(单次会话 43 次重复
git status/ 8.9M token)。