Learning AI Quality 返回 KuthorX Blog II博客首页

第 08 章

第 2 周:Agent 到底是什么?一个 while 循环

不用框架,用 Claude Messages API 从零写一个 agent loop:stop_reason、tool_use / tool_result 配对、五个停止条件和四个常见坑。一段讲解视频,一个可以单步执行、注入故障的互动演示。

Agent 听起来很玄,拆开就三样东西:模型、工具、一个循环。模型说"我要调这个工具",你的代码去调,把结果塞回对话,再问一次模型,直到它说"我做完了"。循环本身二三十行,难的是什么时候停、停得对不对。这一章不用任何框架,用 Claude Messages API 手写这个循环,再从测开的角度看:每一个停止条件都是一个可靠性问题,每一条都要能测。

讲解视频

互动演示

单步执行一个模拟的 agent loop。模型回复是脚本写好的,不调真实 API,但循环逻辑、停止条件和 messages 校验都是真的在跑。每点一步,右边的 messages 数组就长一截,上面显示当前轮数、stop_reason、累计 token 和耗时。可以切换故障场景(模型一直调工具、工具报错、max_tokens 截断、并行结果只回了一个就发请求),也可以换成"只看 tool_use"的朴素写法,对比哪条停止条件在什么时候触发。页面底部有自动判分的练习。

互动演示:单步执行 agent loop 在新标签页打开

三个部件

先说清楚一件事:模型从来不执行工具。它只在回复里写一段结构化的调用请求(tool_use 块):调哪个工具,参数是什么。真正去读文件、跑测试、查数据库的,是你的代码。

部件谁负责做什么
模型Claude API看完整个对话历史,决定直接回答,还是调工具、调哪个
工具你的代码普通函数。输入是模型给的 JSON 参数,输出是字符串(或内容块)
循环(harness)你的代码调模型 → 看 stop_reason → 执行工具 → 把结果追加进 messages → 再调模型;并决定什么时候停

LangChain、Agent SDK、SDK 自带的 Tool Runner 做的也是这件事,只是替你写好了循环。面试问"不用框架怎么写一个 agent",考的就是这个循环里每一步的细节。

一次请求、一次响应

场景:让 Agent “跑一下 login 模块的测试,有失败就查清楚原因”。请求里带上工具定义:

{
  "model": "claude-opus-5-5",
  "max_tokens": 16000,
  "tools": [{
    "name": "run_tests",
    "description": "运行某个模块的单元测试,返回 pytest 输出。需要确认测试是否通过时调用。",
    "input_schema": {
      "type": "object",
      "properties": {"module": {"type": "string"}},
      "required": ["module"]
    }
  }],
  "messages": [{"role": "user", "content": "跑一下 login 模块的测试,有失败就查清楚原因。"}]
}

模型想调工具时,响应的 stop_reason 是 "tool_use",content 里有一个或多个 tool_use 块,每个块有唯一的 id:

{
  "role": "assistant",
  "stop_reason": "tool_use",
  "content": [
    {"type": "text", "text": "先跑一下 login 模块的测试。"},
    {"type": "tool_use", "id": "toolu_01", "name": "run_tests", "input": {"module": "login"}}
  ]
}

执行完工具,用一条 role 为 user 的消息把结果送回去,tool_use_id 必须等于上面那个 id:

{"role": "user", "content": [
  {"type": "tool_result", "tool_use_id": "toolu_01", "content": "1 failed, 2 passed ..."}
]}

Claude API 没有单独的 tool 角色:工具调用放在 assistant 消息里,工具结果放在 user 消息里。示例里的 id 是简写,真实的 id 形如 toolu_01A09q90qw90lq917835lq9。

stop_reason:循环的分岔口

每次响应都带一个 stop_reason,告诉你模型为什么停下。官方文档列出的取值和循环该做的事:

stop_reason含义循环该怎么做
end_turn模型自然说完了正常结束,取最后的文本
tool_use模型要调工具执行全部 tool_use,结果追加回去,继续
max_tokens输出撞到了你设的 max_tokens不是完成。输出被截断;最后一块是没写完的 tool_use 时不能执行,要调大 max_tokens 重试
stop_sequence命中了你设的停止序列按业务处理,一般 agent loop 不设
pause_turn服务端工具(如 web search)的内部循环到了迭代上限把 assistant 内容原样发回去让它接着跑
refusal安全分类器拒绝了(HTTP 200,不是报错)读 stop_details,不执行这一轮的工具
model_context_window_exceeded撞到了模型的上下文窗口上限当成截断,压缩或拆分历史

最常见的 bug 是写成 while resp.stop_reason == "tool_use",循环一退出就当任务完成。这样 max_tokens、refusal 都会被当成成功,测试报告是绿的,用户拿到的是半句话。只有 end_turn 才算正常完成,其他一律走异常分支。

消息历史就是状态

API 是无状态的,每调一次模型都要把整个 messages 列表重新发一遍。Agent 的记忆、进度、做过什么,全在这个列表里。一次正常跑完的 loop,messages 长这样:

[0] user       "跑一下 login 模块的测试,有失败就查清楚原因。"
[1] assistant  text + tool_use(id=toolu_01, run_tests)            stop_reason: tool_use
[2] user       tool_result(tool_use_id=toolu_01)
[3] assistant  text + tool_use(toolu_02, read_file 日志)
                    + tool_use(toolu_03, read_file 源码)          并行调用
[4] user       tool_result(toolu_02) + tool_result(toolu_03)     一条消息装两个结果
[5] assistant  text("test_login_lockout 失败:...")               stop_reason: end_turn

这个列表必须满足几条规则(来自官方文档):

  1. 配对:每个 tool_use 都要有一个 tool_use_id 相同的 tool_result,放在紧接着的下一条 user 消息里,中间不能插别的消息。否则 400:tool_use ids were found without tool_result blocks immediately after。
  2. 并行调用一次性返回:一条 assistant 消息里有几个 tool_use,下一条 user 消息里就放几个 tool_result。只带一部分结果就发请求会 400;拆成两条相邻的 user 消息,API 会合并成一个 turn,不一定报错,但官方文档把它列为错误格式,会让模型以后少做并行调用。
  3. tool_result 放最前:同一条 user 消息里如果还有文本,文本必须排在所有 tool_result 之后。最好干脆只放 tool_result,否则模型容易直接结束这一轮,返回空的 end_turn。
  4. 原样追加 assistant 内容:把 response.content 整个追加回去,不要只抽文本。里面可能有 thinking 块和 tool_use 块,丢了下一轮就对不上。
  5. 工具报错也要回结果:工具抛异常时,返回 tool_result 并设 "is_error": true,内容写清楚错在哪、下一步可以怎么做。不能不回,也不能让循环崩掉。

从测开的角度看,messages 列表是一个可以断言的数据结构。每一轮结束都可以检查"所有 tool_use 都有配对的 tool_result"、“并行调用的结果在同一条 user 消息里”、“文本没有出现在 tool_result 之前”。把这几条写成 harness 的不变量断言,比事后翻日志找 400 快得多。这一周最后一章会给 loop 加完整的 trace,就是把这个列表连同耗时和 token 一起存下来。

停止条件:每一条都是一个可靠性问题

“模型说 end_turn 就停"只是理想情况。模型可能一直调工具停不下来,工具可能卡住,费用可能失控。一个能上线的 loop 至少要有五个停止条件:

停止条件防的是什么怎么测
模型 end_turn正常出口断言最终状态是"完成”,最后一条是 assistant 文本
最大轮数 max_turns模型反复调工具、死循环假模型每次都返回同一个 tool_use,断言恰好在第 max_turns 轮停下,状态是"超出轮数"
token / 成本预算历史越滚越长,一个任务烧掉几十美元假模型返回固定 usage,断言累计超预算后不再发请求
超时工具卡住、模型响应慢假工具 sleep,断言整体耗时不超过上限,返回的是"超时"而不是挂起
重复调用检测同一个工具、同一组参数反复调,没有进展假模型重复同一调用,断言第 N 次时停下;换了参数时不误杀

token 为什么会失控:每一轮都要重发全部历史。设 system 和工具定义占 \(S\) 个 token,每轮新增 \(\Delta\) 个(模型输出加工具结果),第 \(n\) 次调用的输入是 \(S + (n-1)\Delta\),跑 \(N\) 轮的累计输入是

$$ \sum_{n=1}^{N}\bigl(S + (n-1)\Delta\bigr) = NS + \frac{N(N-1)}{2}\Delta $$

和轮数是平方关系。\(S = 400,\ \Delta = 300\) 时,10 轮累计 17,500 个输入 token,20 轮是 65,000,轮数翻倍,token 翻了约 3.7 倍。所以轮数上限和 token 预算是两道不同的闸门,都要有。

和第 1 周连起来:用真实模型跑了 30 次都没出现死循环,不代表不会出现。按 rule of three,这只能说明触发率的 95% 上界大约是 3/30 = 10%。停止条件不能靠"跑几次没事"来验证,要用脚本化的假模型把每一条路径都确定地跑一遍,上面的互动演示就是这个思路。

还有一个联系:一个任务要走 \(n\) 轮,每轮都得做对,整体成功率近似是各轮成功率连乘,和 pass^k 是同一个结构。每轮 99%、走 8 轮,整体只有 \(0.99^8 \approx 92.3\%\)。而且轮与轮之间不独立:第 2 轮读错了文件,后面每一轮都带着这段错误的历史,正是「独立性假设」那一章说的正相关。

四个常见坑

坑现象正确做法
tool_use_id 没对上缺了某个 id 的结果时 400;id 互相填错时不报错,模型拿 A 的结果回答 B结果按 block.id 回填,不要按下标或工具名猜
并行调用的结果分几次发只回了一部分结果就发请求:400;拆成两条相邻的 user 消息:API 会合并,不一定报错,但模型以后会少做并行调用一条 assistant 里的全部 tool_use,结果放进同一条 user 消息
把 max_tokens 当成正常结束报告显示成功,输出是半句话;截断的 tool_use 参数不完整只有 end_turn 算完成;截断的 tool_use 不执行,调大 max_tokens 重试
无限循环模型反复调同一个工具,费用一直涨max_turns + token 预算 + 超时 + 重复检测,少一道就有一条路径没兜住

最小实现

下面是一份完整的最小 agent loop,用官方 Python SDK(anthropic)。写法和官方文档的手动循环一致:client.messages.create(...),看 response.stop_reason,response.content 里 type == "tool_use" 的块带 id、name、input,response.usage 里有 input_tokens 和 output_tokens。

import json
import time

import anthropic

client = anthropic.Anthropic()  # 从 ANTHROPIC_API_KEY 读取凭证
MODEL = "claude-opus-5-5"
TOOLS = [{
    "name": "read_file",
    "description": "读取仓库里的一个文本文件并返回内容。需要看源码或日志时调用。",
    "input_schema": {
        "type": "object",
        "properties": {"path": {"type": "string", "description": "相对仓库根目录的路径"}},
        "required": ["path"],
    },
}]


def read_file(path: str) -> str:
    with open(path, encoding="utf-8") as f:
        return f.read()[:20_000]


TOOL_IMPLS = {"read_file": read_file}


def run_tool(block) -> dict:
    """执行一个 tool_use 块。无论成败,都返回 tool_use_id 相同的 tool_result。"""
    try:
        output = TOOL_IMPLS[block.name](**block.input)
        return {"type": "tool_result", "tool_use_id": block.id, "content": output}
    except Exception as e:  # 工具报错回给模型,而不是让循环崩掉
        return {"type": "tool_result", "tool_use_id": block.id,
                "content": f"{type(e).__name__}: {e}", "is_error": True}


def run_agent(task, max_turns=10, token_budget=200_000, timeout_s=300, max_repeats=3):
    messages = [{"role": "user", "content": task}]
    used, start, seen = 0, time.monotonic(), {}
    for _ in range(max_turns):
        if used >= token_budget:
            return "stopped: token_budget", messages
        if time.monotonic() - start >= timeout_s:
            return "stopped: timeout", messages
        response = client.messages.create(
            model=MODEL, max_tokens=16000, tools=TOOLS, messages=messages,
        )
        # 开了 prompt caching 时,input_tokens 不含缓存部分,还要加上
        # usage.cache_read_input_tokens 和 usage.cache_creation_input_tokens
        used += response.usage.input_tokens + response.usage.output_tokens
        messages.append({"role": "assistant", "content": response.content})  # 整个 content 原样追加

        if response.stop_reason == "end_turn":
            return "done", messages
        # 本例没有服务端工具,不会出现 pause_turn;用了 web search 等服务端工具时,
        # 要把 assistant 内容追加回去再请求一次
        if response.stop_reason != "tool_use":  # max_tokens / refusal / ... 都不算完成
            return f"stopped: {response.stop_reason}", messages

        calls = [b for b in response.content if b.type == "tool_use"]
        for b in calls:
            key = (b.name, json.dumps(b.input, sort_keys=True))
            seen[key] = seen.get(key, 0) + 1
            if seen[key] >= max_repeats:
                return f"stopped: repeated {b.name}", messages
        # 并行调用:全部结果放进同一条 user 消息,而且只放 tool_result
        messages.append({"role": "user", "content": [run_tool(b) for b in calls]})
    return "stopped: max_turns", messages


if __name__ == "__main__":
    status, history = run_agent("读一下 README.md,用三句话总结这个项目。")
    print(status, len(history))

几点说明:

  • 超时有两层。上面只在每轮开始时检查总时长;单次 HTTP 请求的超时靠 SDK 的 timeout 参数,单个工具卡住要靠工具自己的超时兜住。
  • 停在 tool_use 之后的 messages 不能直接续跑。比如重复检测触发时,最后一条 assistant 消息里的 tool_use 还没有结果,要续跑得先补一个 is_error 的 tool_result,否则下一次请求就是 400。
  • SDK 自带的 Tool Runner(client.beta.messages.tool_runner)能替你写这个循环。先手写一遍,才知道它替你做了什么、没做什么。

常见错误说法

  • “Agent 会自己执行工具”:模型只输出调用请求,执行的是你的代码。
  • "stop_reason 不是 tool_use,就说明做完了":max_tokens、refusal 都不是完成,只有 end_turn 是。
  • “并行调用的结果按顺序一条一条发回去”:必须放进同一条 user 消息。
  • “设了 max_turns 就不会失控”:轮数没到,token 也可能超预算,单个工具也可能卡死,要多道闸门。
  • “真实模型跑了很多次都没死循环,所以不用测”:rule of three,30 次零发生只能说触发率上界约 10%。停止条件要用假模型确定地测。

下一章:工具调用与结构化输出。工具的 schema 怎么设计,strict: true 能保证什么,怎么让模型稳定地输出 JSON。