本文是「Agent 开发实践与思考」系列第 1 篇。系列目录:
- 2024
- 07-02 我手写了一个 Agent:从一问一答到”思考-行动-观察”循环(本篇)
chat 模式的限制
2023 年团队陆续在用 GPT 的 API 做一些内部工具,采用一问一答的 chat 模式:用户提问,模型回答,调用结束。这个形态可以处理文案生成、代码解释等纯语言任务,但无法直接处理需要读取外部数据和执行操作的需求。
依赖升级分诊是一个例子。项目有三百多个 npm 依赖,dependabot 每周提交十几个升级 PR。patch 版本可以合并,但 changelog 中的 breaking change 需要确认是否影响项目。模型不能自行获得 changelog 内容,也不知道代码库是否使用了相关 API;这些信息位于 changelog 和代码库中。chat 模式下,模型只能基于已有上下文生成回答,或说明缺少信息,两种结果都不能完成分诊。
依赖升级、文档巡检和日志分诊的处理流程包含重复步骤,但每一步都需要根据中间结果判断。以“这周的更新里有 breaking 且真影响到我们代码的,帮我开个 issue 记下来”为例,处理过程需要拉取更新列表,筛选版本跨度大的条目并读取 changelog,在代码库中搜索 breaking 的 API 名,确认命中后再创建 issue。在一步一问的 chat 模式中,人需要在每一步转交结果:提出问题、粘贴结果,再继续提问。模型无法自行调用用于获取数据和执行操作的接口。
2023 年 11 月 OpenAI 发布 GPTs 和 Assistants API 后,我先试用了 GPTs。它支持自定义指令和文件,但要读取代码库和内部依赖镜像仍需配置 Action,即将 API 文档交给托管环境调用。这里有两个限制:请求和数据需要经过 OpenAI 的服务,而代码库未开放公网,无法通过安全评审;调用过程由托管环境维护,无法查看调用时机、调用内容和失败步骤。Assistants API 可控制的部分更多,但同样使用托管状态机。
“模型调 API”可以实现为一个循环。
一百行的循环
程序向模型提供工具清单,约定模型需要数据时返回结构化的工具调用和参数。程序执行调用,将结果加入对话,再由模型基于结果决定下一步,直到返回最终回答。结构化输出解决调用格式的传递问题,但不能保证模型选对工具、填写正确参数或遵守权限边界。
核心代码约一百行 TypeScript:
interface Message {
role: string;
content: string;
tool_call_id?: string;
tool_calls?: ToolCall[];
}
async function runAgent(userInput: string, tools: Tool[], maxTurns = 10) {
const messages: Message[] = [
{ role: "system", content: SYSTEM_PROMPT },
{ role: "user", content: userInput },
];
for (let turn = 0; turn < maxTurns; turn++) {
const reply = await callLLM(messages, tools);
messages.push(reply);
if (!reply.tool_calls?.length) {
return reply.content;
}
for (const call of reply.tool_calls) {
const result = await executeTool(call.name, call.arguments);
messages.push({
role: "tool",
tool_call_id: call.id,
content: result,
});
}
}
return "超出最大轮数,任务中止";
}循环不包含业务判断。模型提出工具调用并决定下一步,程序维护消息列表、校验调用、执行工具并写回结果。是否读取 changelog、是否搜索代码、是否可以得出结论,主要由模型根据上下文决定;程序仍须独立校验参数、调用次数和权限,不能将模型判断视为事实或授权。
一次调用的消息列表可能包含:用户提问,模型回复“调用 get_updates,参数 ecosystem=npm”,工具返回本周更新列表,模型筛选版本跨度最大的条目后回复“调用 fetch_changelog,参数 package=node-fetch,version=3.0”,工具返回 changelog,模型提取 breaking 条目后回复“调用 grep_code,参数 pattern=require(‘node-fetch’)”,工具返回两处命中,最后模型输出结论:这次升级为纯 ESM,项目里有两处仍在用 require 引入,确实受影响,已整理成 issue 草稿。每一轮模型都能读取此前的消息,这些消息构成下一步判断的输入。
第一个“这周的依赖更新有没有要动代码的”调用完成了更新列表、changelog 和代码搜索的链路。模型先读取更新列表,筛选大版本更新,再在代码命中两处后报告影响,其余十几条更新在最终回复中简要说明。这与单次 API 调用的区别在于,程序根据模型返回的工具调用执行多个步骤;其控制流仍由 for 循环实现。
未采用现成框架的原因
动手前看过 LangChain。当时它已经包含 Agent 相关模块和 ReAct 实现。没有采用它的原因是验证阶段只需要理解核心循环。Agent、Chain、Tool 等抽象会增加需要理解的概念,中间步骤不符合预期时也需要通过框架抽象定位问题。自定义的一百行实现可以直接打印每轮模型的输入和输出,便于定位异常调用。这个阶段优先验证循环和观察调用过程;后续需要框架能力时,再按需求引入。
ReAct 与 function calling
动手前读过 Yao 等人的 ReAct: Synergizing Reasoning and Acting in Language Models(2022 年 10 月)。论文让模型交替生成 Thought(思考)、Action(行动)、Observation(观察):先分析当前缺少的信息,再声明行动,收到环境返回的观察结果后进入下一轮。本文的一百行代码用 function calling 实现了这一交替过程。论文报告了 HotpotQA、ALFWorld、WebShop 等任务上的实验结果,但结果依赖论文中的任务、模型和提示方式,不能推及所有生产 Agent 的幻觉率或成功率。
这里的循环仅借用了 ReAct 的交替过程,不能与 ReAct 等同。论文用文本表达 Thought、Action 和 Observation;这里用 function calling 传递结构化的工具调用。function calling 是一种调用接口,未必包含可见的 Thought,也不要求模型输出完整的推理过程。两者都会将行动结果提供给模型,但 ReAct 是任务执行与提示范式,function calling 是模型与程序之间的调用协议。排查问题时,应记录工具调用、参数、返回结果和最终判断,不应将模型生成的解释视为真实的内部推理。论文还报告了一个对照实验:将推理和行动拆为两个独立模块时,实验效果低于交替进行。该结论同样限于论文的实验设置;工程实现还取决于模型、任务和工具实现。
阅读 ReAct 的相关工作时还看到 Toolformer(Meta,2023 年 2 月)。它先让模型生成可能的 API 调用并执行调用,比较加入调用结果前后后续文本的预测损失,筛选有帮助的调用,再用带调用标记的样本继续训练模型。训练目标包括何时调用、调用什么以及如何使用结果。运行时在提示词中提供工具清单不改变模型参数,两者不同。这个训练路径当时无法用于当前项目,但说明工具调用也可以通过训练进入模型能力,而不只依赖提示词。
循环中的模型、消息和工具
这段代码的运行依赖三个部分。
模型负责选择工具、组织参数并判断任务是否完成。
模型可读取的信息包括系统提示词、工具说明、对话历史和每次工具返回的结果,它们共同构成持续增加的消息列表。模型每次判断均以该列表为输入;列表包含的内容、缺失的信息和消息顺序都会影响输出。
工具定义模型可执行操作的范围。只提供查询接口时,模型不能创建 issue;接口返回哪些字段,也限制了模型可以依据哪些数据生成回答。
框架和模型可以替换,但实现仍需处理模型判断、程序执行和结果回流。后续工作包括工具设计、消息列表管理和错误处理。消息列表会持续增长,需要单独处理其长度和内容选择。
三类问题
循环从跑通到稳定可用时,出现过以下三类问题。
模型可能不按约定格式输出。最初没有使用 function calling,而是在提示词中要求模型输出 JSON。十次调用中通常有一两次会将 JSON 包在自然语言中,或替换字段名,导致程序解析失败并中断循环。换用 2023 年 6 月更新的 function calling 后,模型输出结构化的 tool_calls 字段;在当时的调用中,格式问题减少了大部分,但这不是确定性保证。其余问题来自参数内容,例如将日期传为“上周”。schema、提示词和工具描述可以降低这类问题的概率,程序侧仍需校验解析结果、范围和权限。
模型可能重复无效操作。一类 bad case 是:changelog 提到废弃 API,模型在代码库中搜索后得到空结果,再判断“可能是引入方式不一样”,改用其他关键词继续搜索。别名、解构和全限定名会依次尝试;每轮单独看都有依据,但整体没有产生新信息。最严重的一次达到十轮上限,搜索代码库九次且均为空结果,增加了成本和延迟。处理方式分两层:循环设置最大轮数,达到上限即停止;提示词中列出已执行的动作和结果,避免重复调用。后者不能完全解决问题,因为历史过长时,模型可能忽略较早的内容。
工具错误也可能干扰后续判断。早期工具出错时,我直接将异常信息加入消息列表,例如拉取 changelog 被限流时的英文堆栈。模型收到后可能改变为无关的调用路径,或继续围绕异常信息生成判断。后来工具内部保留技术细节,只返回模型可据以操作的信息,例如“该来源暂时不可用,请换一个镜像重试”。错误返回是模型下一轮的输入,内容需要说明可执行的后续动作,而不是复用运维日志。
当时的结论
完成实现后,我在团队内部分享过一次。当时的判断是:模型能力、提示词和参数可能变化,但“模型判断、程序执行、结果回流”的循环仍是实现工具调用流程的基本结构。工程实现需要持续处理工具设计、消息列表管理和错误返回。
工具描述如何减少误调用、错误返回如何支持下一轮修正、工具粒度如何划分,是下一篇讨论的内容。
