RSS

第 1 章 / 共 21 章

一个 while 循环:Agent 的最小内核

约 8 分钟 更新于

1.1 让你变成中间件的那个瞬间

设想你要让模型帮你统计一个项目里有多少个 Python 文件。你问它,它回答:“你可以运行 find . -name '*.py' | wc -l。”

于是你打开终端,粘贴,回车,看到输出 47,再切回聊天窗口把 47 贴给它。它说:“好的,一共 47 个文件。接下来我需要看看其中最大的几个,请运行 ls -S……”

注意这个循环里各人的分工:**模型负责决定做什么,你负责执行,还负责把结果搬回去。**模型缺的不是智力,是手,和一个愿意帮它跑腿的循环。

那个循环,写出来只有几行。

1.2 唯一的控制信号:stop_reason

模型返回的每条消息都带一个字段 stop_reason,它告诉你模型为什么停下来。对我们来说,全部逻辑压缩成一句判断:

stop_reason含义循环该做什么
tool_use模型想调工具,话还没说完执行工具 → 把结果喂回去 → 再转一圈
其他(end_turn 等)模型说完了退出循环

就这一个布尔判断,撑起了整个 Agent 的自主性。模型决定”要不要用工具、用哪个”,循环负责”执行并回灌”。职责分离得非常干净:模型不碰执行,循环不做判断。

stop_reason 还有几个值你迟早会遇到——max_tokens(输出被截断)、pause_turn(可恢复的暂停)、refusal(模型出于安全拒绝)。第11章会专门处理它们,现在先只认 tool_use 这一个。

1.3 三十行,跑起来

先定义一把工具。第一版只给它 bash——一把万能钥匙,够用来体会自主性:

import subprocess
import anthropic

client = anthropic.Anthropic()
MODEL = "claude-opus-5"
WORKDIR = "/tmp/agent-sandbox"        # 务必用临时目录

SYSTEM = "你是一个命令行助手。需要了解或改动文件系统时,调用 bash 工具。"

TOOLS = [{
    "name": "bash",
    "description": "在工作目录下执行一条 shell 命令,返回它的标准输出与标准错误。",
    "input_schema": {
        "type": "object",
        "properties": {
            "command": {"type": "string", "description": "要执行的 shell 命令"}
        },
        "required": ["command"],
    },
}]

def run_bash(command: str) -> str:
    result = subprocess.run(
        command, shell=True, cwd=WORKDIR,
        capture_output=True, text=True, timeout=60,
    )
    return (result.stdout + result.stderr)[:20000] or "(无输出)"

然后是全书唯一不变的那段代码:

def agent_loop(messages: list) -> None:
    while True:
        response = client.messages.create(
            model=MODEL, system=SYSTEM, messages=messages,
            tools=TOOLS, max_tokens=8000,
        )
        messages.append({"role": "assistant", "content": response.content})

        if response.stop_reason != "tool_use":
            return                                    # 模型说完了,收工

        results = []
        for block in response.content:
            if block.type == "tool_use":
                output = run_bash(block.input["command"])
                results.append({
                    "type": "tool_result",
                    "tool_use_id": block.id,
                    "content": output,
                })
        messages.append({"role": "user", "content": results})

跑起来:

agent_loop([{"role": "user", "content": "统计当前目录下有多少个 Python 文件,并告诉我最大的三个。"}])

它会自己调 find、自己调 ls -S、自己把两次结果串起来给你结论。中间没有你的任何一次复制粘贴。

第1章:Agent 的最小内核
图 1.1:一次 turn 的四个动作。要看的是那根回灌箭头——工具结果被当作新的 user 消息塞回 messages,这才是”循环”两个字的全部含义;没有它,你就还是那个搬运工。

1.4 走一遍:消息数组到底发生了什么

把上面那次运行拆开看,messages 数组是这样长大的:

轮次追加了什么长度
起点user:统计 Python 文件……1
第 1 轮assistant:tool_use(find . -name '*.py')2
user:tool_result → 47 个路径3
第 2 轮assistant:tool_use(ls -S ...)4
user:tool_result → 按大小排序的列表5
第 3 轮assistant:文本结论,stop_reason = end_turn6

三件事值得注意:

  1. **API 是无状态的。**每一轮你都要把完整的 messages 再发一次。所谓”记忆”,此刻就等于这个数组。这也预告了第8、9两章的麻烦:数组会一直变长。
  2. tool_result 必须和 tool_use 一一配对。每个 tool_use_id 要有且只有一个对应的结果块。模型一次返回多个 tool_use 时,所有结果要放进同一条 user 消息里;拆成多条会破坏配对,而且会让模型逐渐不再并行调工具。
  3. **工具失败也要回传。**不要因为命令报错就把结果丢掉——把错误文本当作 tool_result 的内容回去(更严谨的做法是带上 is_error: true),模型看到报错才知道换个思路。悄悄吞掉错误,模型只会以为自己成功了。

1.5 工程现实:这三十行离生产有多远

原课程的作者读过一份真实 Agent 产品的实现,那份代码在对应文件里有一千七百多行,而内核仍然是这段 while True。多出来的一千六百行是十来个状态字段、多条退出路径、token 恢复、主动压缩、权限检查——全是保护机制,不是新的内核。

有一处差异现在就值得记住:生产实现不完全依赖 stop_reason == "tool_use" 来判断是否继续,而是维护一个类似 needsFollowUp 的标志。原因是在流式响应里,tool_use 内容块会先到,stop_reason 更新得不够及时。你的教学版用非流式调用,所以没这个问题;等你换成流式,记得改成”看内容块里有没有 tool_use”。

1.6 该不该做成 Agent

顺手立一个判断标准,免得你把所有东西都做成 Agent。模型厂商自己的建议是把系统分成两类:工作流是用预先写死的代码路径去编排模型调用;Agent 是让模型自己决定流程和工具。Agent 更灵活,但也更贵、更慢、更难预测。

四个问题都答”是”,再上 Agent:

  • 复杂度:任务是不是多步、且事先说不清步骤?(“把这份设计文档变成一个 PR”是;“从这份 PDF 里抽出标题”不是。)
  • 价值:值不值得为它付更高的成本和延迟?
  • 可行性:模型在这类任务上本来就够强吗?
  • 纠错成本:出错了能被发现和回滚吗(有测试、有评审、有版本控制)?

有一个答”否”,就退回更简单的那一层——一次调用,或者一条你自己写死的流水线。

1.7 常见坑

1.8 动手把三十行跑起来

1.9 复述一次 turn 的三个动作