Skip to content

第 4 章 解剖 ReAct 循环 ​

本章代码 code/ch04/bare_loop.py、code/ch04/main.py · 读完约 20 分钟

4.0 上一章留下的问题 ​

第 3 章那张往返图的最后一步,写着"再来一轮"。当时一带而过,现在要正面回答三个问题:

  1. 这一轮到底转几次?
  2. 什么时候停?
  3. 如果它停不下来呢?

这三个问题的答案,构成了 Agent 的心脏。理解了它,后面所有章节(权限、上下文、持久化、前端)都只是在给这颗心脏加外围设备。

理解它的最好方式不是读文档,而是自己写一遍。

4.1 先手写:60 行,不用框架 ​

下面这段代码没有 import agentscope。它直接用 OpenAI SDK,把上一章那张往返图跑起来:

python
async def bare_loop(question: str, max_iters: int = 5) -> str:
    messages = [
        {"role": "system", "content": "你叫 Buddy,需要用工具获取信息时必须调用工具。"},
        {"role": "user", "content": question},
    ]

    for i in range(1, max_iters + 1):
        resp = await client.chat.completions.create(
            model=MODEL_NAME,
            messages=messages,
            tools=TOOLS,
        )
        choice = resp.choices[0].message

        # 没有工具调用,说明它准备直接回答,循环结束
        if not choice.tool_calls:
            return choice.content or ""

        # 有工具调用:把这次 assistant 消息原样存回历史
        messages.append({
            "role": "assistant",
            "content": choice.content,
            "tool_calls": [tc.model_dump() for tc in choice.tool_calls],
        })

        # 逐个执行,把结果作为 tool 消息追加
        for tc in choice.tool_calls:
            name = tc.function.name
            args = json.loads(tc.function.arguments or "{}")
            try:
                result = REGISTRY[name](**args)
            except Exception as exc:      # 工具炸了不能把整个程序带崩
                result = f"工具执行失败:{exc}"
            messages.append({
                "role": "tool",
                "tool_call_id": tc.id,
                "content": str(result),
            })
        # 回到循环开头,再问一次模型

完整文件在 code/ch04/bare_loop.py,加上工具定义和参数表大约 60 行。

这段代码里有四个必须看懂的点:

4.1.1 循环的终止条件是"模型不再要工具" ​

不是"得到了答案",也不是"任务完成了"。判断依据只有一个:这次响应里有没有 tool_calls。

没有,说明模型认为手头的信息够用了,愿意直接说话。有,说明它还想要更多。

这条判断成立的前提是:模型被训练成"信息不足时倾向于要工具"。所以工具描述写得清楚,它才知道该要什么;写得不清楚,它就会硬猜——这又回到第 3 章那个话题。

4.1.2 每次工具调用都要原样存回历史 ​

python
messages.append({
    "role": "assistant",
    "content": choice.content,
    "tool_calls": [tc.model_dump() for tc in choice.tool_calls],
})

这一步很多人会漏。如果不把"模型提出过这个申请"存回去,下一轮模型会看到一条孤零零的工具结果,却不知道是谁要的——API 会直接报错,因为你违反了 tool 消息必须跟在对应 tool_calls 之后的规则。

4.1.3 工具结果要配对回填 ​

python
messages.append({
    "role": "tool",
    "tool_call_id": tc.id,     # 必须和那次调用对得上
    "content": str(result),
})

tool_call_id 就是第 2 章说的那个 id。一个调用配一个结果,数量和顺序都不能乱。模型一次要调三个工具,你就必须回三条结果,缺一条都会报错。

4.1.4 异常必须变成结果 ​

python
except Exception as exc:
    result = f"工具执行失败:{exc}"

这是手写版里唯一一句"防御性代码",也是最有价值的一句。

如果工具抛异常就不管,程序直接崩。正确做法是把异常信息当成工具结果返回给模型——它会看到"Read 失败:文件不存在",然后自己决定换个路径重试,或者告诉你找不到。

模型有自愈能力,前提是你把错误告诉它。

4.2 手写版漏了七件事 ​

上面那 60 行能跑通,甚至能跑得不错。但它是一个"演示级"的循环。

手写循环漏掉的七件事

逐个说,因为它们就是后面几章的主线:

并发执行。模型一次要求读三个文件,手写版是 for 循环串行跑的。并发能省掉三分之二的等待时间——但前提是判断得出"这三个工具互不依赖"。

错误回灌。上面我们做了 try/except,但只做了最粗的一层。工具超时怎么办?返回值太大怎么办?错误信息要不要截断?

迭代上限。有 max_iters,但撞上限之后只是返回一句"达到最大轮数"。用户在界面上看到这句话,完全不知道发生了什么。框架的做法是:追加一次禁用工具的模型调用,逼它把手头的信息总结成一段话。

上下文压缩。手写版没有任何压缩。聊到第四十轮,messages 超出模型窗口,请求直接报 400。

权限拦截。手写版是模型说要调什么就调什么。如果它决定执行 rm -rf ./src,你没有插手的机会。

中断。用户想停,手写版没有入口。循环跑到一半被 Ctrl+C 打断,messages 就停在一个"有调用没结果"的非法状态。

结构化输出。你需要一个 JSON,它会回你一段"好的,我这就为您整理"。得有个机制逼它按 schema 交作业,还要在格式不对时自动重试。

这七件事,一件一件补,就是 AgentScope 的 Agent 类。

4.3 换成 Agent:同样的任务 ​

python
agent = Agent(
    name="Buddy",
    system_prompt="你叫 Buddy,需要用工具获取信息时必须调用工具。回答简短。",
    model=build_model(),
    toolkit=build_toolkit(),
    react_config=ReActConfig(max_iters=10),
)

async for event in agent.reply_stream(UserMsg(name="user", content=question)):
    ...

循环本身消失了。Agent 内部有它,但你看不到 for i in range(...),也看不到 messages.append(...)。

取而代之的是事件。这是本章第二个重点:循环没有消失,它变成了可观察的事件流。

ReAct 循环的三个分支

每一轮,Agent 在一个统一的决策点检查当前状态,然后选一条路走:

分支触发条件产出
推理需要问模型MODEL_CALL_START 及后续内容块事件
行动模型要求调用工具TOOL_CALL_* / TOOL_RESULT_*
退出模型不再要工具,或撞上限REPLY_END

第 2 章那张事件表,现在有了因果解释:为什么会有 MODEL_CALL_START 出现第二次、第三次?因为循环转了不止一圈。

4.4 事件时间线:把循环的每一拍摊开 ​

code/ch04/main.py 里有一个打印器,把事件流按"层级 + 耗时"排成时间线:

>>> 你:现在几点?顺便数一下这句话有多少字:手写循环其实不复杂。
------------------------------------------------------------------------
       0ms  REPLY_START
       1ms    MODEL_CALL_START
     514ms      TOOL_CALL_START       get_current_time
     706ms      TOOL_CALL_START       count_text
     902ms    MODEL_CALL_END         in=422 out=62
     924ms      TOOL_RESULT_END       success
     924ms      TOOL_RESULT_END       success
     926ms    MODEL_CALL_START
    2197ms    MODEL_CALL_END         in=524 out=98

- 现在时间:2026-10-27 11:44:40,星期二(工具返回)
- "手写循环其实不复杂。":共 10 个字符,其中中文 9 个字(含句号)

    2198ms  REPLY_END              completed
------------------------------------------------------------------------
模型调用 2 次|工具调用 2 次|token in=946 out=160|总耗时 2198ms

上面的耗时和 token 数会因模型、网络而异;结构才是要注意的地方。打印器只留有信息量的几类事件,TEXT_BLOCK_START、TOOL_CALL_END 这类空壳和 HINT_BLOCK 这类框架注入的提示都不上时间线。

这里有个反直觉的顺序值得停一下:MODEL_CALL_END 比 TOOL_RESULT_END 早。原因是模型一边生成一边就把"我要调哪两个工具"声明出来了(TOOL_CALL_START 在流里发出),等这段生成彻底结束才发 MODEL_CALL_END;而工具是拿到完整调用参数之后才真正执行的,结果自然排在后面。所以时间线上的时间戳顺序不等于因果顺序——读的时候按缩进层级看,别按行号看。

看这张时间线,能读出三件事:

第一,模型调用是瓶颈。 两次调用占了 2.2 秒里的绝大部分,工具执行只花了 22 毫秒。这解释了为什么第 8 章的上下文压缩那么重要——省 token 就是省钱,省钱就是省时间。

第二,工具是并发的。两个 TOOL_CALL_START 连着出现,没有等彼此。这就是 4.2 节说的"并发执行"。

第三,REPLY_END 的 finished_reason 是 completed。如果它变成 exceed_max_iters,说明模型转晕了;变成 error,说明某一步炸了。前端要按这个字段给用户不同的说法。

这个打印器的价值在于:它是第 12 章前端的原型。前端做的事,就是把这张时间线画成界面——把 MODEL_CALL_START 变成"正在输入",把 TOOL_CALL_START 变成一张卡片,把 TEXT_BLOCK_DELTA 变成逐字打印。

4.5 控制循环:ReActConfig ​

python
from agentscope.agent import ReActConfig

react_config=ReActConfig(
    max_iters=10,              # 最多转 10 轮
    stop_on_reject=False,      # 工具被拒绝时不终止
    structured_output_grace_iters=5,
)

ReActConfig 一共五个字段:

参数默认值作用
max_iters50最多多少轮推理-行动迭代
structured_output_grace_iters5超过 max_iters 后额外允许的轮数,专门用来产出结构化输出
stop_on_rejectFalse工具调用被拒绝时,是否立刻终止回复
interruption_message'I notice the interruption. How can I help you?'被中断时补的那句话
interruption_raise_cancelled_errorFalse中断时是否抛 CancelledError,而不是正常收尾

max_iters 默认 50,这个值比大多数人的直觉大得多。为什么?因为复杂任务确实需要转很多圈:读十个文件、改五处代码、跑三次测试,五十轮并不夸张。

但它也是一个成本开关。每一次循环都是一次 API 调用,50 轮意味着最坏情况下 50 次请求。所以示例里把它压到 10——不是因为它不够用,而是因为你要先知道自己在花多少钱。

撞上限之后会发生什么 不是立刻结束。cur_iter 到了 max_iters,框架先进入宽限期(structured_output_grace_iters,默认 5 轮):这几轮里它给模型注入一条 <system-reminder>,并用 tool_choice 把可选工具限定成结构化输出工具,逼模型把结果吐出来。 宽限期也烧完(cur_iter >= max_iters + grace_iters),才发 EXCEED_MAX_ITERS 事件、以 finished_reason=exceed_max_iters 收场。这个事件在 2.x 里已经标了 deprecated,只为向后兼容而保留,别拿它当主信号。 还有一个更容易踩的点:只要结束时 cur_iter > max_iters,finished_reason 就是 exceed_max_iters,哪怕模型已经写完了正文。 所以别用 finished_reason 判断"有没有拿到内容",那要看 get_text_content()。 这个细节的价值在于:它保证"超时"不会给用户一个空白界面——宽限期里模型往往已经能把该说的说完。

4.6 三个常见错误 ​

错误 1:以为 reply() 只调用一次模型

python
reply = await agent.reply(msg)   # 内部可能调用了 8 次模型

如果按"一次 reply = 一次 API 调用"来估算成本,会严重低估。正确做法是读 reply.usage,或者数 MODEL_CALL_END 事件的个数。

错误 2:把 TOOL_CALL_START 当成"工具已经执行完了"

TOOL_CALL_START 只表示模型提出了申请。工具可能还在执行(TOOL_RESULT_START),可能被权限拦下了(REQUIRE_USER_CONFIRM),也可能失败了(TOOL_RESULT_END 且 state="error")。界面上应该在这三个时刻显示不同状态。

错误 3:在 system_prompt 里写"最多使用三个工具"

模型对这类数量约束的执行力很弱,而 max_iters 是硬约束。能用配置解决的,不要用提示词解决。

4.7 小结 ​

  • 循环的终止条件是模型不再请求工具,不是"任务完成"
  • 手写循环 60 行能跑,但漏了七件事:并发、错误回灌、上限、压缩、权限、中断、结构化输出
  • Agent 把循环藏了起来,用事件流暴露过程
  • 事件时间线是前端界面的原型:MODEL_CALL_START → 正在输入,TOOL_CALL_START → 工具卡片
  • ReActConfig.max_iters 默认 50,是成本开关,也是安全阀

下一章要解决的问题:第 4.1 节的裸循环有一个不易察觉的隐患——工具的工作目录,就是你的家目录。模型说要读 ~/Documents/工资条.xlsx,它就去读;说要往 ~/Desktop 写文件,它就写。它没有"工作边界"这个概念。

教程正文采用 CC BY-NC-SA 4.0 许可,code/ 下示例代码可自由使用