# 从对话到 Agent：工具循环与防坍塌熔断

> 给模型一组工具和一个循环，它就成了 agent——也带来对话应用没有的故障类：路径越界、迭代无上界、长上下文坍塌为同参重复调用。本文拆解一个生产级工具循环的完整设计：最小工具集与路径护栏、零 LLM 预扫描、迭代上限加指纹熔断的双层防线、撰写阶段的工具标记检测重试、确定性进度上报，并给出端到端测试与真实运行数据。

- Canonical (HTML): https://kaguc.com/blog/agent-loop-zh/
- Date: 2026-07-29


*English version: [From chat to agent: tool loops and collapse breakers](/blog/agent-loop/)。“LLM 应用工程”系列第 4 篇。本系列以一个生产级 AI 写作 agent（FastAPI + React + Tauri，351 个测试用例）的实现与实测数据为实证基座。*

## 问题：把循环交给模型之后

前三篇讨论的都是“单次调用”的工程：控制输入、组装提示词、清洗输出。agent 的分水岭在于把**下一步做什么**也交给模型：给它一组工具，执行它的调用，把结果喂回消息列表，再调一次——直到任务完成。

本文的具体场景：给 agent 一个数据文件夹，文件名与内容事先未知，要它自己读文件、判断这是什么实验、写出报告。天真实现就是一个 `while True` 工具循环，它在生产环境会以三种方式失败：

1. **路径不可信。**读哪个文件由模型说了算，一个含 `../` 的参数就能读出数据目录之外的东西。
2. **没有自然终点。**“探索到什么程度算够”由模型判断，循环次数没有上界，成本也没有。
3. **循环坍塌。**最隐蔽的一类：长上下文下，模型退化为以相同参数重复调用同一工具。公开案例 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`。**

```python
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、禁止双下划线属性），其两层沙箱实现是[下一篇](/blog/code-sandbox-zh/)的主题。

## 机制二：探索之前，先零 LLM 扫一遍

模型开始探索前，`scan_folder` 先做一次确定性递归扫描：按扩展名把文件粗分为文档 / 图片 / 数据 / 其他四桶，返回分桶清单与统计，全程零 LLM、零 token。它一方面给用户一个即时的文件全貌快照（作为首个步骤事件的 detail），另一方面成为后续材料体检的确定性输入。原则与[第 11 篇](/blog/deterministic-boundary-zh/)一致：“文件夹里有什么”是代码能回答的问题，模型的探索预算应花在“读哪些、读出什么”上。顺带一句成本设计：探索工具循环跑在便宜档模型上（读文件、决定读什么属于简单任务），撰写与反思才用主模型。

## 机制三：两道防线——预算上界与坍塌熔断

`MAX_TOOL_ITERS = 20` 是预算上界，不是防线：坍塌的 agent 会把 20 轮全部烧在重复调用上，产出为零。真正的防线是 `_LoopBreaker`，核心是一个调用指纹：

```python
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 一条“检测到重复调用同一工具，已熔断探索，凭已读内容撰写”，带着已积累的素材直接进撰写阶段。降级产出加人在环审阅，好于烧满预算后的零产出。

```mermaid
flowchart TD
    A[调用模型，携带 TOOLS] --> B{返回 tool_calls？}
    B -->|无| N[注入提示：继续探索或调 finish_exploration] --> A
    B -->|finish_exploration| W[记录理解，进入撰写]
    B -->|其他工具| F{LoopBreaker 指纹计数}
    F -->|ok| E[执行工具，结果回填消息] --> A
    F -->|第 3 次，仅一次| G[执行工具，另注入收尾提示] --> A
    F -->|第 5 次| X[本轮执行后硬熔断] --> W
```

整个循环外层仍套着 `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 篇](/blog/output-sanitizing-zh/)输出清洗思路在 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 | 按事件数猜进度 | 分母开跑前已知；提前收敛不错标阶段 |

## 适用边界

1. **精确指纹只抓完全坍塌，不抓“游荡”。**同一工具换着参数做低价值调用（把无关文件挨个读一遍）不会触发熔断，那一类要靠预算上界与提示词质量兜底。把指纹做模糊（忽略部分参数）能扩大覆盖，但会误伤合法续读——我们选择窄而准。
2. **nudge 有效的前提是模型还听指令。**收尾提示对轻度绕圈有效；上下文已重度坍塌时模型对注入无响应，只有硬熔断有意义——这正是双阈值分级的理由。
3. **降级产出需要人在环。**熔断后凭部分素材撰写的报告，价值依赖下游有人审阅。若 agent 的产出会被自动执行、部分信息可能造成危害，熔断策略应改为整单失败而非降级。
4. **预枚举进度依赖固定流程。**`_plan_pipeline` 成立的前提是流水线结构在开跑前已定；计划由模型动态生成的开放式 agent 没有先验里程碑序列，进度只能退化为事件计数。
5. **阈值不是普适常数。**20 / 3 / 5 按“读文件夹写报告”的任务规模调定，未在其他任务形态上验证。

## 参考

- qwen-code issue #4695——工具循环坍塌的公开案例（单次会话 43 次重复 `git status` / 8.9M token）。

