0基础入门Agent Harness工程(01):Agent Loop让模型真正执行任务
深入学Agent Harness工程(01):Agent Loop让模型真正执行任务
本篇对应的官方文档
- Learn Claude Code:s01 Agent Loop:支撑“一个工具加一个循环”的学习顺序,以及教学实现与生产系统的边界。
- OpenAI Function Calling:支撑
tools、tool_calls、function.arguments和工具结果回填的协议关系。- Chat Completions create:支撑
messages、Chat Completions 响应对象和工具调用参数的接口边界。本篇主要内容
前置知识已经说明,大模型可以生成回答或工具调用意图,却不会自行进入外部环境执行;Agent 因此需要一条能够行动、观察并继续判断的反馈循环。本篇把这条抽象循环写成第一个 Python 程序,并始终围绕“查找当前目录中的 Python 文件”这次请求,观察模型如何提出动作、Harness 如何执行、结果怎样回到下一轮,以及程序最终怎样停止并形成完整闭环。下篇预告
下一篇从“只有 Bash 一个工具”的局限出发,增加四个原子文件工具、Tool Schema 和 dispatch map,同时验证主循环为什么不需要重写。
一、模型给出命令,为什么还不算完成任务
在前置知识(一)中,我们先认识了大模型的基本能力:它接收当前上下文,然后生成下一步输出。这个输出可以是一段普通回答,也可以是一条命令,或者一个结构化的工具调用意图。但大模型本身不会因为生成了命令,就自动获得操作系统、文件系统或其他外部环境的执行权。
前置知识二继续向前走了一步。我们知道,如果系统想完成真实任务,就不能只调用一次模型。模型提出动作以后,需要有程序执行这个动作,把环境返回的 observation 交还给模型,再让模型根据新的状态决定下一步。这个“判断—行动—观察—继续判断”的反馈过程,就是 Agent Loop 的基本原理。
不过,到这里我们理解的仍然是一条抽象流程。我们还没有看到:程序怎样接收模型提出的动作,怎样执行动作,怎样保存执行结果,又怎样带着新结果再次调用模型。
我们现在把问题缩小到一个具体任务:
帮我看看当前目录里有哪些 Python 文件。
模型可能返回 Get-ChildItem -Filter *.py 或等价 Shell 命令。命令本身可能完全正确,但任务还没有完成:模型只是生成了这段内容,并没有进入操作系统执行它,也看不到命令运行后返回了哪些文件。
如果接下来仍然需要你复制命令、打开终端、执行命令,再把结果粘贴回对话,那么真正连接“模型判断”和“外部行动”的人还是你。系统只是提供了一个会生成命令的模型,还没有形成能够自己继续运行的 Agent。
要去掉这段人工搬运,模型外部至少需要有一个程序完成四件事:把用户问题交给模型,识别模型返回的工具调用,执行对应工具并获得结果,把结果放回对话状态后再次调用模型。负责这些工作的程序就是 Harness;不断重复“调用模型—执行工具—回填结果”的过程,就是 Agent Loop。
第 01 篇要解决的,就是怎样把前置知识中的抽象循环写成第一个真实程序。接下来先看 Agent Loop 每一轮究竟在判断什么,然后沿一次完整请求进入 s01_agent_loop.py,观察用户问题怎样变成工具调用、命令结果又怎样回到模型。
二、Agent Loop 每一轮到底在判断什么
模型提出动作,Harness 掌握执行权。
大模型接收文本或结构化消息,返回新的文本或结构化调用意图。即使返回内容看起来像一条命令,它也只是响应数据。程序是否执行、在哪里执行、允许执行多久、输出怎样截断,都由模型之外的代码决定。
可以把两边的职责压缩成一句话:
Model 决定“想做什么”,Harness 决定“是否以及怎样真的去做”。
这也是 Agent = Model + Harness 最实用的解释。这里的加号不是把两个库安装到一起,而是建立一条闭环:模型产生下一步动作,Harness 解析并执行,结果回到上下文,模型再根据新状态决定是否继续。
先观察这张边界图。蓝色区域只处理消息和调用意图,橙色区域才接触进程、文件和命令输出;两者之间的交接对象不是自然语言猜测,而是 tool_calls。

图里最值得记住的不是左右两块颜色,而是中间那条边界。tool_calls 穿过边界时仍然只是数据;只有 handler 被调用后,外部世界才可能发生变化。因此,权限控制应该放在 handler 之前,而不是寄希望于 system prompt 永远劝住模型。
循环能否继续,只取决于当前消息有没有请求工具。
把人工搬运自动化后,循环每一轮只回答两个问题:
- 模型有没有返回工具调用?
- 如果有,执行结果应该怎样回填?
如果没有工具调用,说明这一轮已经得到普通回答,循环可以结束。如果存在一个或多个 tool_calls,Harness 就逐个处理,并把结果追加到 messages,然后带着扩展后的消息再次调用模型。
整个过程可以写成六个阶段:
准备 messages 和 tools
→ 调用模型
→ 保存 assistant 消息
→ 判断 tool_calls
→ 执行 handler 并追加 tool 消息
→ 回到模型或结束
下面这张循环图将“继续”和“退出”画成两条明确分支。循环的动力不是固定轮数,也不是程序猜测任务是否完成,而是当前 assistant 消息中有没有 tool_calls。

这带来一个重要判断:Agent Loop 本身并不复杂,复杂的是循环周围的保护机制。最大轮次、权限审批、上下文压缩、错误重试和任务持久化都会让工程代码增长,但它们不需要发明第二个主循环。现在已经知道循环为什么继续、为什么结束,下一步可以回到代码,跟着“查找 Python 文件”这次请求观察状态怎样流动。
三、跟着一次请求读懂真实代码
s01_agent_loop.py 虽然是最小版本,已经具备一次工具调用闭环所需的五块结构:
client、MODEL和SYSTEM负责准备模型调用;TOOLS把 Bash 能力描述成模型可见的 Tool Schema;run_bash()负责真正执行命令并返回文本结果;agent_loop()保存消息、调用模型、路由动作和回填结果;- 程序入口负责接收用户输入,并把最终回答输出到终端。
这份清单不是背诵任务,而是一张代码导航图。下面不按文件行号逐段解释,而是跟着“查找 Python 文件”这次请求,看它怎样依次经过这五个部分。本篇进入六层代码地图中的模型交互层、循环与状态层、单工具执行层,其余层暂时还不存在。
第一步,用户问题进入 messages。
第一个对象是 messages。它不是简单聊天记录,而是当前调用可以看见的有序状态。用户要求查看目录时,起点可以只有一条消息:
messages = [
{"role": "user", "content": "列出当前目录中的 Python 文件"}
]
这时 messages 只保存了任务起点,还没有命令、执行结果或最终回答。后面的每一步都会继续扩展同一个列表,因此我们能够从它的增长过程还原整次请求。
第二步,TOOLS 告诉模型可以请求 Bash。
tools 告诉模型有哪些动作可以选择、每个动作叫什么、参数结构是什么。它不会自动绑定 Python 函数,更不会自动执行命令。
TOOLS = [{
"type": "function",
"function": {
"name": "bash",
"description": "运行一条 Shell 命令。",
"parameters": {
"type": "object",
"properties": {
"command": {"type": "string"}
},
"required": ["command"],
},
},
}]
这段定义的输入是模型可见的能力说明,输出只是一个 Schema。name="bash" 是协议里的工具名,command 是模型要填写的参数。它没有指向 run_bash() 的直接引用,所以程序仍然要负责“名称如何落到实现”。
第三步,模型返回调用意图,而不是命令结果。
假设我们不用真实模型,而是构造一条用于学习的 assistant 消息,它可能包含:
{
“role”: “assistant”,
“content”: null,
“tool_calls”: [
{
“id”: “call_001”,
“type”: “function”,
“function”: {
“name”: “bash”,
“arguments”: “{“command”:“Get-ChildItem -Filter *.py”}”
}
}
]
}
这里的 function.arguments 是 JSON 字符串,而不是 Python 字典。Harness 必须先解析,再检查字段是否存在,最后才能把参数交给 handler。这个细节看似琐碎,却是很多最小 Agent 示例的第一个真实错误入口。
下图把 tools 输入与 tool_calls 输出并排放在一起。左边是 Harness 告诉模型的能力合同,右边是模型按合同生成的调用意图;它们共享工具名和参数结构,但方向相反。

因此,Schema 的质量会直接影响模型能否形成正确参数,但 Schema 再严格也不能取代运行时校验。模型可能漏字段、给错类型,甚至返回无法解析的 JSON;Harness 仍需把这些情况当作普通输入错误处理。
第四步,调用意图和执行结果共同进入下一轮状态。
一次工具往返会让 messages 至少增加两条记录。先追加包含 tool_calls 的 assistant 消息,再追加对应的 role="tool" 消息。第二轮调用时,模型同时看到自己的调用意图和执行结果,才能理解“刚才那一步发生了什么”。
静态推演如下:
M1 user
“列出当前目录中的 Python 文件”M2 assistant
tool_calls = [call_001 -> bash(…)]M3 tool
tool_call_id = call_001
content = “app.py\nutils.py”M4 assistant
“当前目录中有 app.py 和 utils.py。”
下面这张状态图强调数组增长,而不是接口调用次数。M2 和 M3 必须一起存在;删掉任一侧,下一轮就失去了完整因果链。

为什么不能只保存工具输出?因为模型需要知道这份输出对应自己提出的哪个动作。为什么不能只保存 assistant 的调用?因为那样模型只知道自己想做过什么,却看不到真实结果。Harness 保存的不是展示日志,而是模型下一轮推理所依赖的状态。
本地案例直接保存 message.model_dump(exclude_none=True),原因正在这里。若为了“清理无用字段”而手工重建 assistant 消息,很容易把 tool_calls 或其中的 id 丢掉。
每个工具调用都有自己的 id,对应的工具结果必须使用同一个 tool_call_id。可以把它理解为消息协议中的外键:
messages.append({
"role": "tool",
"tool_call_id": tool_call.id,
"content": output,
})
content 表示 handler 的输出,tool_call_id 表示这份输出属于哪个调用。如果一条 assistant 消息同时提出两个工具调用,只按数组位置猜测结果归属会非常脆弱;稳定配对依赖的就是调用 ID。
下面的配对图展示两个方向:assistant 消息提出 call_001,Harness 执行后产生结果,再用相同 ID 接回原调用。后续扩展到多个并行工具时,规则仍然不变。

这也是为什么“工具执行失败”通常不意味着丢弃这条消息。更稳的做法是把失败内容作为同一个调用的工具结果回填,让模型知道动作已经尝试但没有成功。是否允许模型修正参数或选择别的动作,则由后续循环和权限策略决定。
第五步,agent_loop() 把前面的对象接成可重复运行的闭环。
本地 OpenAI-compatible 案例的核心可以压缩为下面这段。代码的输入是已有 messages,每轮输出不是单独返回值,而是对同一个消息列表的持续扩展。
def agent_loop(messages: list):
while True:
response = client.chat.completions.create(
model=MODEL,
messages=[{"role": "system", "content": SYSTEM}, *messages],
tools=TOOLS,
max_tokens=8000,
)
message = response.choices[0].message
messages.append(message.model_dump(exclude_none=True))
if not message.tool_calls:
return
for tool_call in message.tool_calls:
arguments = json.loads(tool_call.function.arguments)
output = run_bash(arguments["command"])
messages.append({
"role": "tool",
"tool_call_id": tool_call.id,
"content": output,
})
读这段代码时,应该沿状态变化看,而不是只看 while True:
client.chat.completions.create()消费 system 消息、历史消息和工具定义;response.choices[0].message可能是普通回答,也可能携带工具调用;- assistant 消息先被完整保存;
- 没有
tool_calls时退出; - 有调用时解析 JSON、执行 handler、追加工具结果;
- 下一轮读取扩展后的
messages。
代码没有替我们解决三个问题。第一,json.loads() 失败会直接抛异常;第二,arguments 中可能没有 command;第三,命令即使语法正确,也可能不应该执行。协议解析、参数校验和执行授权是三个不同层次,不能因为代码短就把它们混成一个 try/except。
Tool Schema 和 handler 之间没有自动绑定。
初学 Tool Calling 时,很容易形成一个错误印象:只要把 Python 函数描述成 Schema 传给模型,SDK 就会自动找到并执行这个函数。实际协议没有这层魔法。tools 只是请求中的结构化数据,模型返回的也只是带名称和参数的结构化数据。把 name="bash" 对应到 run_bash(),是 Harness 自己写下的程序逻辑。
第 01 篇只有一个工具,所以代码直接调用 run_bash(arguments["command"]),映射关系被写死在循环里。这样做能让最小主线一眼可见:只要存在工具调用,就解析参数并执行唯一 handler。它也故意暴露了扩展问题——当工具增加到五个、十个甚至动态发现的一百个时,循环里不能继续堆 if name == ...。
因此,本篇要区分三个名称相近但职责不同的对象:
- Tool Schema 面向模型,描述“可以请求什么”;
- tool call 来自模型,表达“这一轮请求什么”;
- handler 属于本地程序,决定“请求最终怎样执行”。
三者的工具名需要一致,但生命周期并不相同。Schema 在每次模型调用时作为能力说明出现;tool call 只属于某一次 assistant 响应;handler 则可能在进程启动时注册并长期存在。把三者混成一个对象,后续很难单独做权限、版本和测试。
run_bash() 自身也不是简单的“调用 subprocess”。它接收已经完成协议解析的 command 字符串,先做教学级危险片段检查,再在当前工作目录启动子进程,收集标准输出和标准错误,最后把文本截断为最多 50,000 个字符。这里发生了至少四次边界转换:
JSON 字符串
→ Python 参数
→ 操作系统进程
→ stdout/stderr 文本
→ tool message content
每次转换都可能失败,且修复策略不同。JSON 无效时不应启动进程;命令被拒绝时应该形成可解释结果;进程超时时要考虑取消;输出过大时要同时保留诊断原文和给模型的摘要。最小案例把它们压缩在一个函数里,是为了看清路径,不意味着生产代码也应该只有一个错误字符串。
OpenAI-compatible 描述接口形状,不决定能力来源。
本地案例使用 openai Python SDK 调用阿里云百炼兼容端点,默认模型是 qwen3.7-plus。这里的 OpenAI-compatible 指请求与响应遵循兼容的 Chat Completions 字段形状,例如 messages、tools、tool_calls 和 tool_call_id;它不表示模型由 OpenAI 提供,也不表示所有兼容服务在流式行为、限制或扩展字段上完全一致。
理解这个边界后,代码中的三个配置项就不会混淆:
api_key负责兼容服务的身份凭据;base_url决定请求发送到哪个服务端点;model是该服务能够识别的模型标识。
这三个字段是接入配置,不是 Agent Loop 的机制核心。即使换成另一个兼容服务,循环仍然围绕同一组消息对象运转;反过来,即使使用同一个 SDK,如果服务不支持相同的工具调用行为,也不能仅凭“请求发成功”就断言 Harness 语义完全一致。
本文不调用任何端点,原因也在这里:我们要验证的是知识结构是否讲清,而不是用某次非确定模型响应证明协议。静态对象足以推演 Harness 如何处理已经收到的 tool_calls,却不能替代后续针对具体服务的兼容性测试。二者都重要,只是回答的问题不同。
最后,用静态对象检查正常路径和失败路径。
本篇不配置模型端点,也不声称某个模型一定返回指定命令。下面固定一组协议对象,只推演 Harness 接到这些对象后会发生什么。
正常路径中,arguments 为:
{“command”: “Get-ChildItem -Name”}
Harness 可以依次完成 JSON 解析、读取 command、交给 run_bash()、截取输出,并追加 role="tool" 消息。此处能够确定的是程序控制流;不能确定的是模型在真实对话中是否会选择 Bash、命令文本是否相同,以及最终自然语言回答怎样组织。
第一个失败分支是非法 JSON:
function.arguments = ‘{“command”: “Get-ChildItem”’
字符串缺少结束大括号,错误发生在协议解析层,handler 还没有开始执行,也没有外部副作用。生产 Harness 应捕获解析异常,形成可定位的失败结果或受控重试,而不是让整个进程突然退出。
第二个失败分支是没有工具调用。assistant 消息只有普通 content,message.tool_calls 为空。此时退出是正常控制流,不是错误。模型可能认为已有信息足够,也可能拒绝执行,Harness 不能把“没调用工具”一律解释为任务成功。
把这两种情况放在一起,能够形成更准确的判断:
没有 tool_calls:循环结束条件;
有 tool_calls 但 arguments 无效:调用解析失败;
arguments 有效但命令不允许:执行授权失败;
handler 已执行但返回错误:工具结果失败。
这些失败属于不同层,后续权限和恢复机制也会分别处理它们。
四、最小 Agent Loop 能做什么,还不能做什么
这个版本已经完成闭环,但还没有形成完整安全系统。
案例中的 run_bash() 已经做了两件保护:拦截少量危险字符串,并设置 120 秒超时。它们适合说明“执行器必须有边界”,但不能构成完整安全模型。
字符串黑名单容易被变体绕过。shell=True 会让命令经过 Shell 解释,管道、重定向、变量展开和子命令都可能扩大行为。以当前进程工作目录执行,也不代表进程只能访问该目录。超时能够停止等待,却不必然清理命令已经派生的所有子进程。输出截断能保护上下文,但会丢失诊断信息。
下图把教学循环和生产 Harness 并排展开。左侧只保留理解闭环所需的最少对象,右侧增加权限、沙箱、超时、审计、幂等、错误分类和可观测性。

图后的结论不是“教学代码不能用”,而是要知道它在证明什么。它足以证明模型调用意图如何变成一次真实 handler 调用,也足以证明结果怎样回到下一轮;它没有证明任何命令都安全,也没有证明任务一定完成。
一个生产 Harness 至少还要回答:
- 谁定义允许调用的工具与参数范围?
- 高风险动作是否需要用户批准?
- handler 在哪个用户、容器或沙箱中运行?
- 超时、取消和子进程怎样传播?
- 工具是否可能重复执行,如何保证幂等?
- 原始输出与给模型的截断输出分别保存在哪里?
- 解析失败、权限拒绝和执行失败怎样分类?
- 循环何时因为最大轮次或预算而强制停止?
这些问题不是要在第一篇一次做完,而是提醒我们:最小循环提供骨架,工程能力来自围绕骨架逐层增加的控制面。
还可以用“控制面”和“数据面”进一步整理这些责任。工具名、Schema、权限规则、超时上限和最大轮次属于控制面,它们决定系统允许怎样行动;具体的用户消息、调用参数、命令输出和错误文本属于数据面,它们在每次运行中流动。若控制面只存在于 prompt 里,程序就无法稳定审计;若数据面没有调用 ID,系统就无法可靠还原因果。
这个区分也解释了为什么 Harness 工程不能只追求“模型这次做对了”。一次正确输出可能来自模型的偶然选择,而工程系统需要保证错误选择进入受控分支:危险命令被拦截、非法参数被报告、超时能够结束、每条结果可以追溯。Agent 的自主性来自模型,系统的确定性边界来自 Harness。
判断一个程序是否形成最小 Agent Harness,可以检查四件事。
看到一段“模型加工具”的代码时,可以用四个问题判断它是否已经形成闭环:
- 模型能否返回结构化工具调用,而不只是自然语言命令?
- 程序是否真正执行了对应 handler?
- assistant 调用消息与工具结果是否完整、正确地追加回上下文?
- 模型是否会基于新结果继续,直到不再请求工具?
四项都成立,才是最小 Agent Loop。只有工具定义而没有执行器,是“模型知道有哪些动作”;只有执行器而没有结果回填,是“一次函数调用”;循环只重复请求却不保存配对状态,则无法形成可靠的因果链。
第一篇最值得带走的不是 20 多行代码,而是一条对象关系:
messages 保存状态,
tools 声明能力,
tool_calls 表达意图,
handler 产生现实结果,
tool_call_id 把结果接回原因,
Agent Loop 让这一过程持续。
下一篇我们会在这条主链完全不变的前提下,把一个 Bash 工具扩展为五个原子工具。真正新增的不是更多 if/elif,而是一层可维护的工具注册与分发结构。
更多推荐



所有评论(0)