我手写了一个 Agent:从一问一答到"思考-行动-观察"循环

3 分钟阅读
·

本文是「Agent 开发实践与思考」系列第 1 篇。系列目录:

  • 2024
    • 07-02 我手写了一个 Agent:从一问一答到”思考-行动-观察”循环(本篇)

为什么 chat 模式不够用

2023 年团队陆续在用 GPT 的 API 做一些内部工具,都是一问一答的 chat 模式:用户提问,模型回答,结束。这个形态做文案生成、代码解释这类纯语言任务没问题,但很快碰到它做不了的需求。

典型的场景是依赖升级分诊。项目里挂着三百多个 npm 依赖,dependabot 每周提来十几个升级 PR。patch 版本可以闭眼合,但总有几条的 changelog 里写着 breaking change。这条 breaking 对项目到底有没有影响,模型自己回答不了——它不知道 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 一层套一层,我只想验证一个一百行就能说清的循环,却得先读懂一堆和当前问题无关的概念;中间任何一步不符合预期,都要先读懂框架的抽象才能定位问题。自己写一百行,每一轮模型的输入输出都能直接打印,哪一步走歪了一眼可见。这个阶段我对 Agent 的理解还没成型,可调试性比功能齐全重要得多。先把最小骨架跑明白,以后需要框架的哪些能力,再按需要引。

想法来自 ReAct

这个循环的思路不是我自己想出来的,动手之前就读过论文。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 传递结构化的工具调用,后者主要是一种调用接口,未必包含可见的 Thought,也不要求模型输出完整的推理过程。两者都可以把行动结果回流给模型,但 ReAct 是一种任务执行与提示范式,function calling 是模型与程序之间的调用协议。排查问题时,应该记录工具调用、参数、返回结果和最终判断,不应把模型生成的解释自动当成真实的内部推理。论文里还有一个对我有启发的对照实验:把推理和行动拆成两个独立模块各做各的,效果不如交替进行。这个结论同样限于论文的实验设置,工程上还要看模型、任务和工具实现。

读 ReAct 的时候顺着相关工作还翻到 Toolformer(Meta,2023 年 2 月),思路更进一步:它不是简单地在训练语料里标注一句”这里调一次 API 对预测有帮助”,而是先让模型生成可能的 API 调用,再实际执行调用,比较加入调用结果前后对后续文本的预测损失,筛选出有帮助的调用,最后用这些带调用标记的样本继续训练模型。这样模型学习的是何时调用、调用什么以及如何使用结果。它和运行时在提示词中提供工具清单不是一回事,后者不需要改变模型参数。这条路当时还落不了地,但它回答了一个我心里有的问题:工具调用不该只靠提示词技巧,也可以通过训练成为模型能力的一部分。

拆开看,转起来的就三样东西

把这一百行代码拆开,一个 Agent 跑起来的时候,只有三样东西在起作用。

第一样是模型。所有判断从它这里出:选哪个工具、参数怎么组、任务算没算完成。

第二样是模型能看到的信息。系统提示词、工具说明、对话历史、每次工具返回的结果,全在一个不断变长的消息列表里。模型每做一次判断,依据就是这整份列表。列表里有什么、缺什么、按什么顺序排,直接决定判断质量。

第三样是模型能动的手,也就是工具。工具定义了它影响世界的边界:只给了查询接口它就开不了 issue,接口返回什么字段它就只能基于这些字段推理。

我在笔记里写下一句话:框架会换,模型会换,工程上要打磨的就是这三样东西。当时直觉接下来的大部分工作都会落在它们上面,尤其是第二样。消息列表怎么管,看起来是个会越滚越大的问题,先记下,以后单独写。

踩过的三个坑

循环跑通到稳定可用,中间隔了不少坑,说三个有代表性的。

第一个是模型不按规定格式输出。最初我没用 function calling,是在提示词里要求模型输出 JSON。十次里总有一两次,它会把 JSON 裹在自然语言里,或者自作主张换个字段名,程序解析失败,整个循环断掉。换成 2023 年 6 月更新的 function calling 之后,模型输出的是结构化的 tool_calls 字段,格式问题在当时的调用中减少了大部分,但这不是确定性保证。剩下的一小部分是参数内容本身不对,比如日期传成”上周”。这类问题可以用 schema、提示词和工具描述降低概率,但仍要在程序侧做解析、范围和权限校验,不能指望提示词根治。

第二个是死循环。有一类 bad case 反复出现:changelog 里提到一个废弃的 API,模型去代码里搜,结果为空,它判断”可能是引入方式不一样”,换个关键词再搜,还是空,再换。别名、解构、全限定名轮着试,每一轮的推理单独看都合理,合起来就是原地打转。最严重的一次跑满了十轮上限,搜了九次代码库,全是空结果,成本和延迟都白花了。对策有两层:循环加最大轮数硬限制,跑满就停;再把已经执行过的动作和结果在提示词里明示,让它看到自己试过什么、别再重复。后者有效但不彻底,历史一长模型就顾不上前面的内容,当时没有好办法。

第三个是工具报错污染上下文。早期工具出错时,我直接把异常信息塞回消息列表,比如拉取 changelog 被限流时的一长串英文堆栈。模型看到之后经常会”认错”,然后换一条奇怪的路径继续,或者干脆围着报错开始推理,越跑越偏。后来改成工具内部消化技术细节,只返回模型能用的话,比如”该来源暂时不可用,请换一个镜像重试”。错误信息不是给运维看的日志,是给模型看的下一轮输入,写法标准完全不一样。

此时的判断

做完这个小东西,我在团队内部分享过一次。结尾的判断原话大概是:模型能力半年一换,今天写的提示词和参数明年大概率过时,但”模型判断、程序执行、结果回流”这个循环骨架看起来不会变。值得投入的是骨架周边的东西:工具怎么设计,消息列表怎么管理,错误怎么处理。

工具设计这个话题当时已经攒了一些素材:工具描述怎么写模型才不乱调,报错信息怎么写模型才能自我修正,粒度怎么切。下篇写这个。


527 字 · 35 段落
xi ming

Written by xi ming You should follow him on Github

评论与讨论