来源与致谢
本节整理自 Learn Claude Code 官方站点 s01:Agent Loop 与 GitHub 源项目
shareAI-lab/learn-claude-code · s01_agent_loop
,按 MIT License 授权整理;同时迁入阿衡学习笔记 s01-Agent循环.md
的补充理解。本站内容为非官方学习笔记,不代表 shareAI-lab 或 Anthropic
官方立场。
最小的代理是一个循环,该循环调用模型、运行工具并将结果反馈回来。
学习目标
- 说清楚为什么“一个循环 + 一个 Bash 工具”就是最小 Agent。
- 区分模型的
tool_use意图和 Harness 写回的tool_result。 - 理解 LLM 本身没有记忆,它能"知道"之前发生了什么,完全靠 Agent 把完整的
messages每次都整个发过去。 - 说清楚
state和真正发给 LLM 的 request body 有什么区别。 - 判断教学版
stop_reason判断和生产级流式 follow-up 判断的差异。
Agent While Loop
这是 Agent Harness 的核心。它是一个不断重复的过程,模型在其中决定是否调用工具,工具的结果又被反馈回模型,形成一个持续推进任务的闭环。
运行该工具,将结果追加到 messages[] 中,然后将其回传。
"One loop & Bash is all you need": 一个工具 + 一个循环 = 一个 Agent。
Harness 层: 循环 — 模型与真实世界的第一道连接。
没有循环,就没有 agent。
quote:核心原则
真正的 agent 起点,是把真实工具结果重新喂回模型。
问题
你提出了一个问题给大模型:“帮我读取下我的目录下有哪些文件,并且执行XXX.py”。
模型能输出一条 bash 命令,但输出完了就停了,它不会自己跑,也不会看到结果后继续推理。
你可以手动跑一遍,把输出粘贴回对话框,让它接着干。下一个命令出来,你再跑一遍、再贴回去。
每一个来回,你都在做中间层。而把它自动化,就是这一章要做的事。
这一章要解决什么问题
语言模型本身只会"生成下一段内容"。
它不会自己:
- 打开文件
- 运行命令
- 观察报错
- 把工具结果再接着用于下一步推理
如果没有一层代码在中间反复做这件事:
发请求给模型
-> 发现模型想调工具
-> 真的去执行工具
-> 把结果再喂回模型
-> 继续下一轮
那模型就只是一个"会说话的程序",还不是一个"会干活的 agent"。
这一章的核心目标只有一个:把"模型 + 工具"连接成一个能持续推进任务的主循环。
名词解释
loop(循环)
只要任务还没做完,系统就继续重复同一套步骤。
turn(轮)
最小版本里,一轮通常包含:
- 把当前消息发给模型
- 读取模型回复
- 如果模型调用了工具,就执行工具
- 把工具结果写回消息历史
tool_result(工具执行结果)
要重新写回对话历史,让模型在下一轮处理,能看见的结果块。
state(运行状态)
主循环继续往下走时,需要一直带着走的那份数据。
最小版本里最重要的状态:
messages- 当前是第几轮
- 这一轮结束后为什么还要继续
名词流转时序图
标准的 Agent 架构的层次是:
User
└── Agent(控制层)
├── loop(主循环)
├── state(循环状态)
└── Tool(工具执行层)
↕ (Agent 向外调用)
LLM(独立的模型服务)
LLM 不属于 Agent:
- Agent = "跑在你机器上、管控循环的那段代码"(loop + state + tool executor)
- LLM = 一个通过网络调用的推理服务,它本身不感知循环,也不管 state
- Agent 负责"驱动",LLM 负责"推理",两者通过 API 边界分离。这也是为什么 Agent 可以换模型、可以限速、可以做权限拦截,而 LLM 对这些一无所知
一个 Prompt 从用户输入到任务完成,完整走一遍所有名词:
正在渲染图示...
时序图的「可单步播放」版,一步步看 messages、turn_count、transition_reason 如何随两个 Turn 变化:
逐步拆解
① 用户把 Prompt 交给 Agent
用户只跟 Agent 的入口(loop)打交道,不直接碰 state,更不认识 LLM。
② Agent 把消息写进 state
loop 把用户的 Prompt 存进 state.messages。state 是整个循环的"记忆",它唯一的职责就是把上下文带到下一轮。
③ Agent 调用 LLM(Turn 1)
loop 把当前 messages + 工具定义(tools schema)一起发给 LLM。
注意:LLM 在 box 外面,这是一次跨越 API 边界的网络调用,LLM 完全不知道有个循环在等它。
④ LLM 回来说"我要调工具"
LLM 返回的 stop_reason = tool_use,意思是:"我不打算直接给你答案,我需要先执行一个工具。" 这一步 LLM 只是表达意图,它自己不会真的去跑任何东西。
⑤ Agent 真正执行工具
loop 解析出 LLM 想调的工具,把任务交给 Tool 执行层,拿到原始输出,再包装成规范的 tool_result 结构(带上 tool_use_id,让 LLM 下一轮知道这条结果对应哪次调用)。
⑥ 工具结果写回 state,准备下一轮
loop 把 tool_result 追加进 state.messages。此刻 messages 里已经有三条:
| # | role | 内容 |
|---|---|---|
| 1 | user | 用户原始提问 |
| 2 | assistant | LLM 刚才那条回复——内容是"我要调 run_bash('ls')",这就是所谓的"assistant 意图":LLM 没有直接给答案,而是在回复里声明了自己要用哪个工具、传什么参数,本质上还是一条普通的 assistant 消息 |
| 3 | user | 工具的真实执行结果(以 tool_result 块的形式写回,role 仍是 user) |
同时,loop 把 transition_reason 标记为 "tool_result"。这是 loop 自己用来判断"要不要继续转"的标志:如果这一轮结束时有工具结果需要送回模型,就打上这个标记,下一轮继续;如果 LLM 直接给了最终答案(stop_reason = end_turn),标记就是 None,loop 退出。
⑦ Agent 再次调用 LLM(Turn 2)
loop 把更新后的完整 messages 再次发给 LLM。LLM 这次看到了工具的真实结果,可以给出最终答案,stop_reason = end_turn。
⑧ Agent 把答案返回给用户 loop 把 LLM 的最终回复交还给用户,循环结束。
note:读图要点
state贯穿始终,是唯一"记忆载体" -turn是每次"模型调用 → 可能执行工具"的完整一轮 -tool_result必须写回 state.messages,否则下一个 turn 的模型看不到执行结果,下一轮 LLM 读到 tool_result 才知道"上一步执行了什么" - loop 靠 LLM 返回的stop_reason决定走哪条路,tool_use 就执行工具继续,end_turn 就退出 -transition_reason是 loop 自己写的记录,是 loop 内部记录,LLM 不可见,loop 自己用来追踪"为什么还在跑"
state 与 request body 的区别
state.messages 是每次调用 LLM 时的 request body 的核心字段,每轮都把它整个发出去
state 里还有两个字段是 LLM 永远看不到的:
| 字段 | 会发给 LLM 吗 | 作用 |
|---|---|---|
messages | ✅ 是,每轮整个发出去 | LLM 的上下文输入 |
turn_count | ❌ 否 | loop 自己记"跑了几轮了" |
transition_reason | ❌ 否 | loop 自己判断"要不要继续转" |
所以更准确的理解是:
state是 loop 的工作台,它既放着"下次要发给 LLM 的材料"(messages),也放着"loop 自己用来决策的内部变量"(turn_count、transition_reason)。
request body 只是从 state.messages 里取出一部分构建出来的,state 本身比 request payload 大。
关于 stop_reason
stop_reason 就是 LLM API 直接返回的,它是响应体里的一个字段,不是 Agent 自己算出来的。
Anthropic 的响应结构大概长这样:
{
"role": "assistant",
"content": [...],
"stop_reason": "tool_use"
}Agent 拿到响应后,直接读 response.stop_reason 这个字段来决定下一步怎么做。
这个模式是业界标准,但字段名不统一:
| 厂商 | 字段名 | "要调工具"的值 | "正常结束"的值 |
|---|---|---|---|
| Anthropic | stop_reason | "tool_use" | "end_turn" |
| OpenAI | finish_reason | "tool_calls" | "stop" |
| Google Gemini | finish_reason | "STOP" + 看 content | "STOP" |
语义完全一致,叫法不同而已。本质都是:LLM 在回复里告诉 Agent "我为什么停下来了",Agent 凭这个信号决定走哪条分支。
stop_reason 是响应 JSON 里的一个字段,Agent 解包后读取它,LLM 本身不知道 Agent 会拿它做什么判断。
解决方案
正在渲染图示...
一个 while True 循环,模型调用工具就继续,不调用就停。整个过程只有两个信号:
| 信号 | 含义 | 循环动作 |
|---|---|---|
stop_reason == "tool_use" | 模型举手说"我要用工具" | 执行 → 结果喂回去 → 继续 |
stop_reason != "tool_use" | 模型说"我做完了" | 退出循环 |
最小心智模型
user message
|
v
LLM
|
+-- 普通回答 ----------> 结束
|
+-- tool_use ----------> 执行工具
|
v
tool_result
|
v
写回 messages
|
v
下一轮继续
important:关键点
工具结果必须重新进入消息历史,成为下一轮推理的输入。 如果少了这一步,模型就无法基于真实观察继续工作。
工作原理
将这个过程翻译成代码。分步来看:
第 1 步:把用户的问题作为第一条消息。
messages = [{"role": "user", "content": query}]第 2 步:将消息和工具定义一起发给 LLM。
response = client.messages.create(
model=MODEL, system=SYSTEM, messages=messages,
tools=TOOLS, max_tokens=8000,
)第 3 步:追加模型回答,检查它是否调了工具。没调 → 结束。
messages.append({"role": "assistant", "content": response.content})
if response.stop_reason != "tool_use":
returnwarning:初学者常见错误
很多初学者会只关心"最后有没有答案",忽略把 assistant 回复本身写回历史。这样一来,下一轮上下文就会断掉。
第 4 步:执行模型要求的工具,收集结果。
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,
})第 5 步:把工具结果作为新消息追加,回到第 2 步。
messages.append({"role": "user", "content": results})完整循环
def agent_loop(messages):
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})不到 30 行,这就是最小可运行的 agent harness 内核。模型负责决策(要不要调工具、调哪个),harness 负责执行(调了就跑、结果喂回去)。后面的章节都在这个循环上叠加机制,循环本身始终不变。
用 state 对象包装
教学版直接传 messages 列表已经够用。生产版通常把它包进一个 state 对象,同时带上 loop 自己需要的内部变量:
state = {
"messages": [...], # 发给 LLM 的上下文
"turn_count": 1, # loop 内部计数,LLM 看不到
"transition_reason": None, # loop 内部记录,LLM 看不到
}完整循环升级为:
def agent_loop(state):
while True:
response = client.messages.create(
model=MODEL,
system=SYSTEM,
messages=state["messages"],
tools=TOOLS,
max_tokens=8000,
)
state["messages"].append({
"role": "assistant",
"content": response.content,
})
if response.stop_reason != "tool_use":
state["transition_reason"] = None
return
results = []
for block in response.content:
if block.type == "tool_use":
output = run_tool(block)
results.append({
"type": "tool_result",
"tool_use_id": block.id,
"content": output,
})
state["messages"].append({"role": "user", "content": results})
state["turn_count"] += 1
state["transition_reason"] = "tool_result"turn_count 和 transition_reason 是 loop 的内部变量,不会出现在发给 LLM 的 request body 里。
关键数据结构
1. Message
{"role": "user", "content": "..."}
{"role": "assistant", "content": [...]}Agent 里的 messages 是 LLM 下一次推理的唯一输入。
LLM 本身没有记忆,每次调用都是全新的。它能"记住"之前发生的事,唯一的原因就是 Agent 把完整的 messages 列表整个发过去了。它读到的不是"对话记录",而是一份完整的任务日志:用户要求什么、自己之前说了什么、工具返回了什么结果。
一个具体的对比:
聊天展示层: "删掉这条消息" → 界面变干净,没别的影响
Agent messages: "删掉这条消息" → LLM 下一轮就再也看不到这个信息
→ 它会当作这件事从没发生过
→ 推理结果可能完全错误
在 Agent 里操作 messages,本质上是在编辑 LLM 的工作记忆。每一条消息的存在与否,直接决定 LLM 下一轮能看到什么、能推理出什么。这也是为什么"工具结果必须写回 messages"是整章最强调的事。少了这个,LLM 的下一轮推理就是在无中生有。
2. Tool Result Block
{
"type": "tool_result",
"tool_use_id": "...",
"content": "...",
}tool_use_id 的作用:告诉模型"这条结果对应的是你刚才哪一次工具调用"。
试一下
教学 demo 提示:代码会执行模型生成的 shell 命令。建议在一个临时测试目录中运行,避免影响你的项目文件。s03 会讲真正的权限系统。
下载代码:
- 下载
s01-agent-loop.zip
含 code.py · requirements.txt · .env.example
准备(首次运行):
python3 -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
pip install -r requirements.txt
cp .env.example .env
# 官方 API:填入 ANTHROPIC_API_KEY 和 MODEL_ID
# 第三方 API:填入 BASE_URL、API_KEY 和 MODEL_ID运行:
python code.py试试这些 prompt:
- 创建一个叫 hello.py 的文件,打印 "Hello, World!"
- 列出当前目录里所有的 Python 文件
- 当前的 git 分支是什么?
观察重点
模型什么时候调用工具(循环继续),什么时候不调用(循环结束)?
接下来
现在模型手里只有 bash 一个工具,读文件要 cat,写文件要 echo ... >,找个文件要 find,又丑又容易出错。
s02 Tool Use → 给它 5 个真正的工具,会发生什么?模型会不会一次调用多个工具?几个工具同时跑会不会互相踩?
扩展:深入 Claude Code 源码
以下内容基于 Claude Code 源码
src/query.ts(1729 行)的核查。核心差异就两个:Claude Code 不看stop_reason字段而是检查内容里有没有 tool_use 块(因为流式响应中 stop_reason 不可靠);Claude Code 有更多的退出路径和恢复策略做生产级保护。
教学版的 30 行 while True 就是 CC 1729 行的核心。 下面每一项都是在这个核心上叠加的保护机制。
一、循环结构差异
教学版检查 response.stop_reason。CC 不把它作为循环继续的唯一依据。
流式响应中 stop_reason 可能还没更新,但内容里已经有 tool_use 块了。CC 用 needsFollowUp 标志:接收到流式消息时(query.ts:830-834),只要检测到 tool_use 块就设为 true;QueryEngine.ts 会从 message_delta 捕获真实 stop_reason 用于其他逻辑,但 query loop 本身靠 needsFollowUp 决定是否继续。
// query.ts:554-558
// stop_reason === 'tool_use' is unreliable.
// Set during streaming whenever a tool_use block arrives.
let needsFollowUp = false;二、State 对象 10 字段(教学版只用 messages)
| # | 字段 | 用途 | 对应章节 |
|---|---|---|---|
| 1 | messages | 当前迭代的消息数组 | s01 |
| 2 | toolUseContext | 工具、信号、权限上下文 | s02 |
| 3 | autoCompactTracking | 压缩状态追踪 | s08 |
| 4 | maxOutputTokensRecoveryCount | token 恢复尝试次数(上限 3) | s11 |
| 5 | hasAttemptedReactiveCompact | 本轮是否已尝试响应式压缩 | s08 |
| 6 | maxOutputTokensOverride | 8K→64K 的升级覆盖 | s11 |
| 7 | pendingToolUseSummary | 后台 Haiku 生成的 tool use 摘要 | s08 |
| 8 | stopHookActive | 停止钩子是否产生阻塞错误 | s04 |
| 9 | turnCount | 轮次计数(maxTurns 检查) | s01 |
| 10 | transition | 上一次继续原因 | s11 |
注:
taskBudgetRemaining(query.ts:291)是 loop-local 局部变量,不在 State 上。源码注释明确写了 "Loop-local (not on State)"。
三、多条退出和继续路径
教学版只有 1 条退出路径(模型不调工具就结束)。生产版有多条退出和继续路径,覆盖 blocking limit、prompt too long、model error、abort、hook stop、max turns、token budget continuation、reactive compact retry 等场景。每种场景都有对应的恢复或退出策略。
四、流式工具执行和 QueryEngine
CC 的 StreamingToolExecutor(query.ts:561)让工具在模型还在生成时就开始并行执行(根据工具是否 concurrency-safe 决定并发或独占)。QueryEngine.ts 额外加了费用超限、结构化输出验证失败等保护。教学版不实现这些,目标是概念清晰,不是性能极致。
一句话:1729 行的 query.ts 核心就是 30 行 while True。所有复杂字段和退出路径都是保护机制。先理解核心循环,后面的一切自然展开。
它如何接进整个系统
从现在开始,后面所有章节本质上都在做同一件事:往这个循环里增加新的状态、新的分支判断和新的执行能力。
| 章节 | 增加的能力 |
|---|---|
s02 | 往里面接工具路由 |
s03 | 往里面接权限判断 |
s06 | 往里面接子 Agent 隔离 |
s07 | 往里面接按需加载的知识 |
s11 | 往里面接错误恢复 |
初学者最容易犯的错
- 把工具结果打印出来,但不写回
messages→ 模型下一轮根本看不到真实执行结果 - 只保存用户消息,不保存 assistant 消息 → 上下文会断层
- 不给工具结果绑定
tool_use_id→ 模型会分不清哪条结果对应哪次调用 - 一上来就把流式、并发、恢复、压缩全塞进第一章 → 让主线变得非常难学
- 以为
messages只是聊天展示 → 在 agent 里,messages更像"下一轮工作输入"
小结
最小 Agent Harness 不是把模型提示词写得更长,而是把模型的 tool_use 意图交给真实工具执行,再把 tool_result 写回 messages,让下一轮模型调用能基于真实观察继续推理。后续章节增加工具、权限、Hook、任务状态和压缩机制,都是围绕这个循环补控制面。
summary:一句话记住
Agent Loop 的本质,是把"模型的动作意图"变成"真实执行结果",再把结果送回模型继续推理。
代码下载
本节完整源码,支持官方 Anthropic API 和任何兼容 Anthropic 协议的第三方服务。
- 下载
s01-agent-loop.zip
含 code.py · requirements.txt · .env.example