深入学Agent Harness工程(01):Agent Loop让模型真正执行任务

本篇对应的官方文档

本篇主要内容
前置知识已经说明,大模型可以生成回答或工具调用意图,却不会自行进入外部环境执行;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 永远劝住模型。

循环能否继续,只取决于当前消息有没有请求工具。

把人工搬运自动化后,循环每一轮只回答两个问题:

  1. 模型有没有返回工具调用?
  2. 如果有,执行结果应该怎样回填?

如果没有工具调用,说明这一轮已经得到普通回答,循环可以结束。如果存在一个或多个 tool_calls,Harness 就逐个处理,并把结果追加到 messages,然后带着扩展后的消息再次调用模型。

整个过程可以写成六个阶段:

准备 messages 和 tools
→ 调用模型
→ 保存 assistant 消息
→ 判断 tool_calls
→ 执行 handler 并追加 tool 消息
→ 回到模型或结束

下面这张循环图将“继续”和“退出”画成两条明确分支。循环的动力不是固定轮数,也不是程序猜测任务是否完成,而是当前 assistant 消息中有没有 tool_calls

在这里插入图片描述

这带来一个重要判断:Agent Loop 本身并不复杂,复杂的是循环周围的保护机制。最大轮次、权限审批、上下文压缩、错误重试和任务持久化都会让工程代码增长,但它们不需要发明第二个主循环。现在已经知道循环为什么继续、为什么结束,下一步可以回到代码,跟着“查找 Python 文件”这次请求观察状态怎样流动。

三、跟着一次请求读懂真实代码

s01_agent_loop.py 虽然是最小版本,已经具备一次工具调用闭环所需的五块结构:

  1. clientMODELSYSTEM 负责准备模型调用;
  2. TOOLS 把 Bash 能力描述成模型可见的 Tool Schema;
  3. run_bash() 负责真正执行命令并返回文本结果;
  4. agent_loop() 保存消息、调用模型、路由动作和回填结果;
  5. 程序入口负责接收用户输入,并把最终回答输出到终端。

这份清单不是背诵任务,而是一张代码导航图。下面不按文件行号逐段解释,而是跟着“查找 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。”

下面这张状态图强调数组增长,而不是接口调用次数。M2M3 必须一起存在;删掉任一侧,下一轮就失去了完整因果链。

在这里插入图片描述

为什么不能只保存工具输出?因为模型需要知道这份输出对应自己提出的哪个动作。为什么不能只保存 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 字段形状,例如 messagestoolstool_callstool_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 消息只有普通 contentmessage.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,可以检查四件事。

看到一段“模型加工具”的代码时,可以用四个问题判断它是否已经形成闭环:

  1. 模型能否返回结构化工具调用,而不只是自然语言命令?
  2. 程序是否真正执行了对应 handler?
  3. assistant 调用消息与工具结果是否完整、正确地追加回上下文?
  4. 模型是否会基于新结果继续,直到不再请求工具?

四项都成立,才是最小 Agent Loop。只有工具定义而没有执行器,是“模型知道有哪些动作”;只有执行器而没有结果回填,是“一次函数调用”;循环只重复请求却不保存配对状态,则无法形成可靠的因果链。

第一篇最值得带走的不是 20 多行代码,而是一条对象关系:

messages 保存状态,
tools 声明能力,
tool_calls 表达意图,
handler 产生现实结果,
tool_call_id 把结果接回原因,
Agent Loop 让这一过程持续。

下一篇我们会在这条主链完全不变的前提下,把一个 Bash 工具扩展为五个原子工具。真正新增的不是更多 if/elif,而是一层可维护的工具注册与分发结构。

Logo

这里是“一人公司”的成长家园。我们提供从产品曝光、技术变现到法律财税的全栈内容,并连接云服务、办公空间等稀缺资源,助你专注创造,无忧运营。

更多推荐