从零构建 Agent 框架(一)写出对话循环与工具调用

从零构建 Agent 框架(一)写出对话循环与工具调用
Asaakii关于本教程: 这是「从零构建 Agent 框架」系列的第一篇。实现思路参考 czl9707/build-your-own-openclaw 的前两步,但代码由我重新组织为一个带工作目录限制的最小示例。它适合用来学习循环和工具调用,不是生产环境的安全方案。
很多 Agent 框架看起来很复杂:会读文件、改代码、跑测试,还能在任务失败后继续尝试。但如果先把外围能力拿掉,最小实现只需要四样东西:模型、消息历史、工具定义,以及一个负责重复调用它们的循环。
这一篇会实现一个终端 Agent。它能在 workspace/ 内列文件、读文件、写文件,还能运行固定的测试命令。这里刻意不提供任意 bash 工具,因为“模型能生成命令”和“模型应该获得任意命令权限”是两件事。
先看完成后的结构
1 | minimal-agent/ |
准备 Python 3.10 及以上版本,然后在空目录执行:
1 | python -m venv .venv |
这里的 LiteLLM 是一层薄封装:它把 OpenAI、Anthropic、Gemini 等几十家提供商的接口统一成同一套 completion() 调用,这样换模型时改一个环境变量就够了,不用重写请求代码。你也可以直接用各家官方 SDK,本文选它只是为了让「换模型」这件事更简单。
锁定版本。 LiteLLM 迭代很快,返回结构和函数签名偶尔会变。本文代码在 litellm 1.x 下写成并跑通;建议装完后立刻用
pip freeze > requirements.txt记下确切版本,半年后照着复现才不会因为接口变动而报错。
本文代码固定以 OpenAI 模型为例。换模型提供商时,除了修改 MODEL,还要把 check_config() 中的环境变量检查改成对应提供商的凭据,并确认该模型支持 tool calling。LiteLLM 提供了 supports_function_calling() 用于检查这件事。LiteLLM Function Calling 文档
一、对话循环:模型不会替你保存历史
调用模型 API 时,服务端通常不会替你的程序记住上一轮对话。程序要把已经发生过的消息保存在 messages 中,再在下一次请求时一起传给模型。
在写代码前,先看清 messages 到底是什么:它是一个列表,每个元素是一条消息字典,最基本的三种角色是:
1 | messages = [ |
模型的「记忆」就是这个列表本身:每多聊一轮,你就往列表尾部追加。等引入工具后,还会出现第四种角色 tool,结构稍复杂,我们到第三节看到真实数据时再展开。现在只需记住一件事:历史是你自己维护的一个列表,不是模型端的状态。
先写 llm.py。这个文件只做两件事:读取本地环境变量,调用模型。
关于
async。 下面的代码用了async def/await,这是 Python 的异步写法。本篇其实每一步都是顺序执行、彼此等待,async 并不会让它更快;我们提前用它,只是为了后续几篇要「同时发起多个工具调用 / 多个模型请求」时不必推翻重写。如果你还不熟悉 async,可以先把它当成「函数前面要加async、调用前面要加await」的固定写法,不影响理解本篇的主线。
1 | # llm.py |
再写一个只会聊天的 main.py,注意这是一个临时试验版,第三节接上工具后会被完整版覆盖,现在只用它验证「历史增长」这条数据流。此时它没有工具,只能把回复写回历史:
1 | import asyncio |
这里有个容易被略过的细节:message.model_dump(exclude_none=True)。call_llm 返回的 message 不是普通字典,而是 LiteLLM 的一个 pydantic 对象;model_dump() 把它转回字典好塞进 messages。exclude_none=True 也不是可有可无:模型回复里常带一堆值为 None 的字段(比如没有工具调用时的 tool_calls),有些提供商在下一次请求里收到 content: null 这类字段会直接报错。去掉空字段,是为了让这条历史能安全地再发回去。
这段程序能连续对话,是因为 messages 在增长,不是因为模型本身拥有长期记忆。它也埋下了一个后续问题:历史越长,请求越贵,最终还会碰到上下文窗口。系列后面会再实现持久化和压缩;现在先把这条数据流看清楚。
二、工具由说明书和执行函数组成
模型不能直接调用你的 Python 函数。你需要先把工具的名字、用途和参数结构告诉模型,这份描述就是 tool schema。模型根据用户任务选择工具,再返回工具名和 JSON 参数;程序负责校验并真正执行。
一个 tool schema 单独拿出来长这样,它就是一段 JSON,用 JSON Schema 描述这个工具收哪些参数:
1 | { |
description 不是给人看的注释,而是模型判断「什么时候该用这个工具」的唯一依据,写得含糊,模型就会用错或不用。这一点后面排错时会再回到。
下面的 tools.py 只提供四个能力:列文件、读文件、写文件和运行测试。前三个工具的路径会被限制在 workspace/ 内,最后一个工具只运行固定的 python -m pytest -q,而不是接受模型拼出的任意 shell 命令。
1 | # tools.py |
这里特意保留了一个教学上的缺口:write_file 仍是直接覆盖,它只证明 Agent 能写文件,不证明这种编辑可靠。真正的代码编辑至少还要有「先读后改、展示 diff、确认读取后文件没有被外部修改、替换目标唯一」几道门;下一阶段再把它做成安全编辑工具。与之相对,read_file 现在先检查字节数和二进制特征,list_files 也跳过最常见的依赖与缓存目录。仅靠“读完再截断字符”并不够,因为超大文件已经被整个读进内存了。
这段代码里有两个初学者容易卡住的地方,先说清楚:
先说 register 装饰器在做什么。它只是个「登记表」:每定义一个工具函数,就把「这个函数」和「它的 schema」成对存进全局字典 TOOLS。这样后面 tool_schemas() 能一次性取出所有 schema 发给模型,execute_tool() 能按名字反查到真正要执行的函数。如果你觉得装饰器绕,完全可以先在脑子里把它翻译成一句话:TOOLS["read_file"] = (schema, read_file 函数)。装饰器只是把这句登记自动化了,换成手写一个列表也不影响理解。
再说模型传回来的参数是一段 JSON 字符串,不是 dict。这就是 execute_tool() 里要先 json.loads(raw_arguments) 的原因:模型说「我要调用 read_file,参数是 {"path": "README.md"}」,但这串东西到你手里时是文本,你得先解析、再校验它确实是个对象,才能 function(**arguments) 展开成关键字参数去调用。模型偶尔会吐出不合法的 JSON,所以这里用 try/except 兜住,把错误当成工具结果返回给模型而不是让程序崩溃,模型看到报错后,往往会自己重试一次。
而这一节最重要的,是边界:模型只能看到 schema,真正的 Python 函数在你的进程里执行。模型提出“请读 README.md”,不等于它获得了主机上任意文件的读取权。这个权限由 workspace_path() 决定:它把相对路径拼进 WORKSPACE 后用 resolve() 算出真实路径,再用 is_relative_to() 确认没有靠 ../ 逃出去。模型能「请求」什么,和你的程序「允许」什么,是两条独立的线。
三、把模型和工具接成循环
现在写 agent.py。它的工作是重复执行同一个协议:请求模型,检查是否有 tool_calls,执行每个工具,把结果以 role=tool 写入历史,再请求模型。
协议范围。 本文使用的是 OpenAI-compatible Chat Completions 的消息形状:模型调用在
assistant.tool_calls中,结果以单独的role="tool"消息回传。它不是所有模型 API 的通用 JSON。比如 Anthropic 原生 Messages API 把tool_use与tool_result放在内容块中;循环职责相同,但消息适配层必须按提供商协议实现。
flowchart TD
A["用户消息加入历史"] --> B["请求 LLM:历史 + 工具 schema"]
B --> C{"返回 tool_calls?"}
C -->|"是"| D["校验参数并执行受限工具"]
D --> E["将 tool result 写入 messages"]
E --> B
C -->|"否"| F["返回最终文本"]
1 | # agent.py |
这段循环里藏着工具调用协议的三条硬规则,初学者改代码时最容易在这里踩坑:
tool_choice="auto"(在llm.py里)是把「要不要用工具」的决定权交给模型:auto让它自行判断,none强制它别用工具,required强制它这轮必须用。本文用auto,因为我们要的正是「模型自己决定何时读文件、何时收尾」。role=tool的消息必须带tool_call_id,而且要和 assistant 那条里的tool_calls[i].id一一对上。模型是靠这个 id 把「哪条结果对应哪次调用」认回去的。漏了 id、或对不上,API 会直接报错。- 一条 assistant 消息里有几个
tool_calls,你就得追加几条tool消息,一个都不能少,然后才能再次请求模型。这也是为什么循环里用for tool_call in tool_calls把每一个都处理完、各自 append 一条结果,再进入下一轮。如果模型一次要求读三个文件,你只回了两条结果,下次请求就会失败。
MAX_TURNS 很小,却是一个不能省略的工程细节。模型可能因为工具报错、提示词冲突或任务不清楚而重复调用。没有上限,你无法控制时间和费用,也无法判断它是否卡住。
最后用 main.py 把入口接起来:
1 | # main.py |
运行 python main.py 后,可以先问:
1 | 列出当前工作目录的文件,然后读取 README.md,用一句话说明项目用途。 |
终端里大致会看到这样一段回显:
1 | 你: 列出当前工作目录的文件,然后读取 README.md,用一句话说明项目用途。 |
如果模型先调用 list_files,再调用 read_file,最后生成总结,说明这个 Agent Loop 已经跑通。模型决定工具序列,你的程序只负责在既定边界内执行并返回观察结果。
把这一轮的 messages 摊开看
上面那三行回显背后,messages 列表到底装了什么?这是整篇文章最该看懂的地方。下面把这一轮结束后的历史逐条列出来(省略了少量字段,突出结构):
1 | [ |
把这几条对着第三节的流程图看,整个循环就具体了:
- 第 1 条
user进来,程序带着全部历史 + 工具 schema 请求模型 → 得到第 2 条。 - 第 2 条里有
tool_calls,于是循环没有返回,而是执行两个工具,追加第 3、4 条tool结果。 - 带着更长的历史再请求一次 → 这次返回的第 5 条没有
tool_calls,if not tool_calls成立,循环结束,把content返回给用户。
几个值得停下来看的点:
- 第 2 条的
content是None,模型「决定调工具」的那一轮通常不说话。这正是前面exclude_none=True要处理的字段之一。 arguments是字符串(注意里面的\"),不是字典,所以execute_tool要先json.loads。- 一次 assistant 消息里并排了两个工具调用,就对应下面两条
tool结果,id严格配对。少一条,第 5 步的请求就会失败。 - 到第 5 条为止,模型全程没有「记住」任何东西,它每次都是把这一整个列表重新读一遍。所谓「Agent 的记忆」,物理上就是这个不断变长的列表。
补一层流式实现:事件不是历史
到目前为止,call_llm() 直接交回完整回复,所以代码里看不见流式输出。接入流式模型时,最容易犯的错误是把每个 text_delta 或“参数片段”直接 append 到 messages。这样一条 assistant 回复会被拆成几十条伪消息,恢复会话、统计 token、重试工具调用都会失去边界。
《动手学 Pi》的模型协议和 Agent Loop 采用了更稳的两层模型:事件只用于过程展示,完整消息才进入 transcript。 一次典型工具调用的关系是:
1 | 模型事件:start → text_delta / toolcall_delta → toolcall_end → done |
这不是某家 API 的字段规定,而是一条运行时边界:UI 可以逐字显示 delta,日志也可以记录它;但历史只在流结束、已得到完整 assistant 后追加一次。工具结果继续按本文的 tool_call_id 配对,下一次模型请求读到的才是一段可恢复、可复现的完整往返。
如果以后把本篇改成流式版本,先写三个测试比先接 SDK 更重要:输入 messages 不被运行过程改坏;一轮流只落一条 assistant 历史;工具结果写回后,第二次请求确实带上这条完整 assistant 与结果。这样不会因为“终端看起来在流式输出”就误以为会话状态是正确的。
跑不通时,先查这几处
初学者八成的时间不是花在写代码,而是花在「为什么它不动」。下面是这套最小实现最常见的几种卡点,按现象归类:
报错「不支持 function calling」或启动就退出。这是 check_config() 拦下来的,说明 MODEL 指向的模型不带工具调用能力,或者环境变量没生效。先确认 echo $OPENAI_API_KEY 有值、echo $MODEL 是你要的模型;换过终端窗口后 export 会失效,要重新设。
鉴权失败、401、AuthenticationError。key 没设、设错、或复制时带了空格引号。注意 key 是跟着这个终端会话的,新开一个 tab 要重新 export。
模型「不肯」调用工具,直接用文字瞎编答案。这几乎总是 description 写得太含糊,或系统提示没说清「只能通过工具访问文件」。回去把工具的 description 写具体、把系统提示里的边界写死(本文 main.py 的系统提示就是这个作用)。这也是前面强调「description 是给模型看的、不是给人看的」的原因。
报 工具参数不是合法 JSON。模型偶尔会吐出坏 JSON。本文已经用 try/except 把它兜成一条工具结果返回给模型,通常模型看到后会自己重试。如果反复出现,多半是参数 schema 太复杂,简化 parameters 往往就好了。
role=tool 相关的 API 报错(tool_call_id 对不上 / 缺少 tool 消息)。十有八九是你改动循环时,漏了「每个 tool_call 都要回一条对应结果」这条规则。对照上一节摊开的 messages 检查 id 是否一一配对。
卡住不停地调用工具,直到提示达到上限。这正是 MAX_TURNS 存在的意义,它兜住了你的时间和账单。真卡住时,把每轮的 messages 打印出来看模型在重复什么,通常是某个工具一直返回同样的错误、而提示又没告诉它「此路不通就换个思路」。
调试这类程序有个通用心法:当你不知道发生了什么,就把 messages 整个打印出来。Agent 的所有状态都在这个列表里,看它就能还原模型「看到了什么、于是决定做什么」。
动手改改看
看懂不等于会写。下面三个改动难度递增,建议至少动手前两个:
- 加一个
delete_file工具。照着write_file的样子写 schema 和函数,记得路径同样要过workspace_path()。跑通后想一想:模型现在能删文件了,系统提示要不要补一句约束? - 给
run_tests加一个可选的test_path参数,让模型能只跑某个测试文件而不是全量。写完顺带思考:这个参数要不要也过一遍路径校验?如果模型传进来的是../../etc,会发生什么? - 把
print(f" 调用工具: {name}")换成打印完整的工具参数和返回结果。这其实就是一个最小的「可观测性」改造,真实 Agent 框架里,日志和 trace 是排错的命脉。
四、这个示例解决了什么,还没有解决什么
它已经具备了一个工具型 Agent 的基本闭环:
messages保存会话状态。- tool schema 告诉模型有哪些可用能力。
role=tool把真实执行结果交回模型。- 循环让模型根据新的观察继续工作。
- 工作目录、固定测试命令、超时、输出截断和最大回合数限制了最常见的失控路径。
但不要把它当成安全沙箱。workspace_path() 只能约束本文写出的文件工具;pytest 运行的是 Python 代码,恶意测试仍可能访问网络、环境变量或其他主机资源。真实项目还需要容器或虚拟机隔离、权限审批、网络策略、日志和人工复核。
这也是为什么成熟 Agent 框架会显得更重。它们不仅要解决“模型怎样调用工具”,还要解决“谁能批准动作”“执行发生在哪里”“失败后怎样恢复”“如何检查结果”。最小实现的价值在于先把第一件事看明白,再逐层补上工程约束。
下一篇做什么
下一篇先不扩大能力,而是把这个循环工程化:保证 assistant 与 tool 消息严格配对,分开保存应用历史与 API 请求,并用预算和结构化状态报告“完成”还是“未完成”。
安全编辑、会话持久化与上下文压缩会在这之后依次加入;Skills 则放在有了稳定的会话和权限边界之后。这样“能力很多”和“常驻上下文很大”才不会被混成同一个问题。
参考资料
- 教程参考:czl9707/build-your-own-openclaw
- LiteLLM 工具调用:Function Calling
- LiteLLM 模型与提供商:Providers
- 流式事件与完整 transcript 的可运行教学实现:动手学 Pi · Checkpoint 03 |Checkpoint 07
- 可对照阅读:Pi Agent 技术架构深度解析 | Codex 技术架构深度解析 | Anthropic Skills 技术架构深度解析








