Skip to content

项目一:Minimal Agent(最小 Agent) ​

难度:⭐☆☆ 前置:01 ~ 03 章 + 裸 API 手写 Agent目标:不用任何框架,纯 OpenAI SDK 写一个能用工具、能多轮的最小 Agent。

这个项目不追求功能多,只追求一件事:你能讲清 tool loop 的每一行。如果讲不清"模型把 tool_calls 交回来之后,代码到底做了哪几步、为什么必须这么做",那用再多框架也只是会用、不会懂。

一、从一个问题开始 ​

拿"北京比上海热几度"做全文主线。它是个好样本:要让模型回答,得先查两个城市的温度,再做比较。模型自己不知道实时天气,所以它必须"提出"两次查询,代码"执行"这两次查询,再把结果交回模型算差值。模型和代码的分工,在这个问题里最清楚。

先看终点长什么样:

text
> 北京比上海热几度?
[turn 1] 调用 get_weather(北京), get_weather(上海)   in=310 out=42
[turn 2] (无工具调用,作答)                          in=520 out=38
北京 32°C,上海 30°C,北京比上海热 2 度。
[本次共消耗 910 tokens]

整篇文章要回答四个问题,它们也恰好是 tool loop 的四个关节:

  1. 模型怎么"提出"它想调用工具?
  2. 代码怎么"执行"并兜底?
  3. 结果怎么"喂回"模型,让它能继续?
  4. 循环怎么"停",不会一直转下去?

为了把最小闭环先跑通,第一节先简化成单工具——"北京现在多少度",讲清五步动作;再回到双城市问题讲多轮和并行;最后看出错和复盘。

二、最小闭环:一次 tool call 的五个动作 ​

先用更短的问题"北京现在多少度"讲最小闭环。它只触发一次工具调用,但完整的五步动作一步不少。记住一条总纲:模型不会执行任何工具,它只输出"我想调 get_weather,参数 {city: 北京}"这样一个结构化请求;真正执行的是你的代码。这是 工具调用循环 的本质,也是理解一切 Agent 的钥匙。

动作一:代码把 messages + tools 发给模型 ​

程序先准备两条消息——一条 system prompt 定人设,一条 user 消息放问题——连同工具定义一起发给模型。到这一步,一个取舍就要定了:要不要用框架。

  • LangChain / LangGraph Agent — 开箱即用、组件多:循环、消息入栈、工具分发和终止条件全被封装,出问题要扒源码 / 代价是"能跑但说不清"。
  • OpenAI Agents SDK — 提供 scaffold 但暴露循环:介于两者之间,但仍替你决定了消息结构和错误处理 / 代价是边界由框架定。
  • OpenAI SDK 裸写 — 手写循环、分发、错误回传:每一行(请求、assistant 入栈、tool 结果回传、终止)都可见可改 / 代价是约 150 行代码。

本项目选裸 SDK。目标是"能讲清 tool loop"而非"最快出 demo";一旦循环被封装,你就失去了和课程 裸 API 手写 Agent 逐一对照的能力。框架替你省了什么、又藏了什么,留到 07 章和项目三再回头比较。

动作二:模型返回 tool_calls,表示它"想"调什么 ​

模型收到请求后,可能直接回答,也可能返回 tool_calls——一个结构化请求,说它想调 get_weather,参数是 {"city": "北京"}。注意:模型只是"提出"调用,没有执行任何东西。

这里的关键是工具 schema 要窄而清楚,不要写成"万能查询工具":

  • 一个"通用查询工具"接收任意意图 — schema 简单:模型乱调、参数难校验、危险动作难拦截 / 代价是触发准确率低。
  • 多个窄工具,职责单一 + enum + 必填 — 触发准、好校验、好限权:schema 变长 / 代价是多占一点输入 token。

本项目里 get_weather 只查城市当前天气,calculator 只做四则运算,边界写进 description 和参数约束。窄工具也让你更容易判断"模型这次该不该调"。schema 怎么定义、模型怎么触发,见 Function Calling。

动作三:代码解析参数并执行真实函数 ​

模型返回的 arguments 是一段 JSON 字符串,不是字典。你要先 json.loads,再执行。这里有个容易被忽略的取舍:模型给的参数不是可信输入。

  • 信任 schema 合法就直接执行 — 少代码:合法 JSON 可能缺字段、类型对但值越界、城市名拼错 / 代价是运行时才崩或返回错误结果。
  • 代码做字段校验 + 类型校验 + 业务校验 — 多几行:能区分"缺字段 / 类型错 / 未知工具 / 业务拒绝"并结构化回传 / 代价是校验代码。

选后者,三道校验:字段存在 → 类型合法 → 业务范围(比如城市在支持列表里)。模型"成功发出 tool call"不等于参数可用;校验失败要转成结构化错误交回模型,而不是抛堆栈退出。参数校验和服务端边界的细节见 Function Calling。

calculator 是同一道理的极端案例:它的输入是模型/用户生成的表达式字符串,坚决不能用 eval。

  • eval(expr) — 一行实现:能执行 __import__("os").system(...),等于把代码执行权交给模型 / 代价是严重安全漏洞。
  • ast.literal_eval — 只支持字面量:算不了 2 + 3 * 4 / 代价是不够用。
  • 受限算术解析器(用 ast 走一遍只允许数字和运算符) — 多几十行:只能四则运算,没有任意代码路径 / 代价是实现量。

教训是工具的"能力边界"要靠代码约束而非模型自觉;任何接受外部字符串又想执行的工具,先问"最坏输入能造成什么"。危险工具调用和注入防护在 Agent 评估 里有专门的安全评测样本。

动作四:代码把结果作为 role:tool 消息塞回 messages ​

工具执行完,代码要把结果包装成 role: "tool" 的消息,带上对应的 tool_call_id,塞回 messages。这一步是 tool loop 最容易踩的坑:

  • 只回传工具结果,不回传 assistant 的 tool_calls — 省几条消息:模型"忘了"自己调过工具 / 代价是答非所问。
  • 手动摘要历史再发给模型 — 省输入 token:丢掉 tool_call_id 配对,并行调用结果串位 / 代价是多轮出错。
  • assistant 消息原样入栈 + 每个工具结果带 tool_call_id — 多占 token:完整还原决策链 / 代价是上下文增长。

选原样入栈。少 assistant 消息或 tool_call_id,下一轮模型就看不懂上下文。这是 工具调用循环 里消息增长和回传的核心。

动作五:模型读到结果,决定继续调用还是作答 ​

代码把更新后的 messages 再次发给模型。模型这次看到了工具结果,它要么继续调用别的工具,要么直接给出最终回答。循环到此有了出口——但出口怎么设,是个取舍:

  • 只靠模型自己停止 — 优雅少代码:陷入"再调一次"时无限循环,工具返回错误时尤甚 / 代价是无界成本。
  • 固定最大轮数 + 总 token 预算 — 简单可控:可能过早截断本该多轮的任务 / 代价是失败要可解释。
  • 模型停止 + 重复调用检测 + 预算 — 最稳:要记录每次工具调用做去重 / 代价是实现稍复杂。

选最大轮数 + 总 token 预算做硬上限,重复调用检测做软停止。学习项目要能回答"为什么停了"——是模型完成、触达轮数还是预算耗尽。

五个动作的最小骨架 ​

把五步拼起来,循环其实就这么几行(完整版见 裸 API 手写 Agent):

python
messages = [system_message, user_message]

for turn in range(max_turns):
    response = call_model(messages, tools)
    assistant_message = response.message
    messages.append(assistant_message)          # 动作二:assistant 原样入栈
    log_usage(response.usage)

    if not assistant_message.tool_calls:
        return assistant_message.content         # 动作五:模型作答,出口

    for call in assistant_message.tool_calls:    # 动作三:解析并执行
        tool_result = safe_run_tool(call)
        messages.append({                        # 动作四:结果回传
            "role": "tool",
            "tool_call_id": call.id,
            "content": json.dumps(tool_result, ensure_ascii=False)
        })

return "达到最大轮数,未能完成任务。"

读这段骨架时,沿着 model -> tool -> observation -> model 走一遍:哪是工具、哪是循环、哪是日志,应该一眼分得清。这也是为什么前面坚持不用框架——框架把这些步骤藏起来,你就失去了这种"一眼看穿"的能力。

三、回到主线:多轮与并行 ​

现在把问题换回"北京比上海热几度"。它和"北京现在多少度"的差别在于:模型会在同一轮里提出两个并行调用——一次查北京、一次查上海。

这就引出一个最容易写错的点:并行调用要遍历全部 tool_calls,不能只处理 tool_calls[0]。如果只取第一个,上海的温度就丢了,模型拿不到完整数据,要么编一个、要么再查一轮,成本和质量都受影响。

正确做法是遍历这一轮里所有的 tool_calls,每个都执行,每个结果都带上各自的 tool_call_id 回传。模型在下一轮看到两个温度,再做比较:

text
turn 1 messages 增长:
  system + user
  + assistant(tool_calls: [get_weather(北京), get_weather(上海)])
  + tool(id=A, 结果: 北京 32°C)
  + tool(id=B, 结果: 上海 30°C)
turn 2:模型看到两个结果,直接作答

这里还有一条边界:并行只适合互不依赖的操作。查两个城市天气可以并行,因为彼此无关;但"先查订单、再用订单号查物流"不能并行——第二个调用要用第一个的结果,必须串行。串行任务里,第二步能不能提前执行、为什么不能,trace 里应该一眼能看出来。依赖和并行的判断,在 工具调用循环 里有更细的说明。

到这一步,主线问题已经能完整跑通了。但一个能跑的 demo 离"能用"还差最关键的一关:出错怎么办。

四、当事情出错 ​

用几个失败样本当第三轮轨迹。它们不是边角料,而是 tool loop 真正成熟的标志——能处理失败,才算一个 loop。

未知城市、缺参数、非法 JSON:用户问"火星现在多少度",模型可能照样提出 get_weather(火星)。代码层校验发现"火星"不在支持列表,返回结构化错误 {"ok": false, "error": "unknown_city"},再作为 tool 消息回传给模型。模型看到错误后,会向用户说明查不了或请求补充。关键:程序不崩溃,不抛 Python 堆栈,把错误转成模型能理解的结构化反馈。

calculator 危险输入:如果模型生成 calculator("__import__('os').system('rm -rf /')"),受限解析器只允许数字和运算符,这串东西在语法阶段就被拒,根本不会进 eval。安全边界靠代码约束,不靠模型自觉。

死循环:更阴险的失败是工具反复返回相同或无效结果,模型不停"再试一次",费用和延迟悄悄失控。本地用 mock 工具就能复现:让 get_weather 一直返回空,看模型会不会无限重试。恢复保护后,至少要有三道闸:

  • 最大轮数(硬上限)
  • 总 token / 费用预算(硬上限)
  • 重复调用检测(软停止:连续 N 次调同一工具同一参数就停)

还有一个常被忽略的区分:可重试 vs 不可重试 vs 需人工确认。查询类工具(天气、搜索)失败可以有限重试;但有副作用的工具(发邮件、扣款、删数据)失败时,要先确认它到底执行了没有,不能盲目重试——否则一封邮件发两次、或者扣两次款。这条边界在 Function Calling 的服务端执行部分讲得更细。

处理完失败,loop 才算稳。但跑稳之后,还差最后一块:能不能复盘它到底怎么跑的、贵在哪一步。

五、把它变成能复盘的工程 ​

一个能跑的 loop,如果出错时只能盯着终端猜,那它还只是个脚本。要变成能复盘的工程,最少要加一样东西:结构化日志。

最朴素的做法是每轮打印一行 JSON:

json
{"run_id":"run-001","turn":1,"event":"model_call","input_tokens":310,"cached_tokens":0,"output_tokens":42,"latency_ms":900}
{"run_id":"run-001","turn":1,"event":"tool_call","tool":"get_weather","args":{"city":"北京"}}
{"run_id":"run-001","turn":1,"event":"tool_result","tool":"get_weather","ok":true}
{"run_id":"run-001","turn":2,"event":"finish","total_tokens":910}

这里同样有个取舍:日志要不要一上来就接 tracing 平台。

  • 直接 print 自然语言 — 零成本:事后无法程序化统计 token、延迟、错误率 / 代价是复盘靠肉眼。
  • 一开始就接 LangSmith / OpenTelemetry — 数据全:引入依赖、配置和成本,且你还不确定要记什么 / 代价是过早抽象。
  • 先输出结构化 JSON 行 — 轻量可解析:后期能直接喂给成本和 trace 工具 / 代价是要自己约定字段。

选先结构化 JSON 行。字段对齐 08 章的成本口径——input / cached / output / total 分开记,因为不同供应商的 cached、cache_write、reasoning token 含义不同,混在一起算不准成本。等你想清楚要统计什么、要接哪个平台,再决定——而不是一开始就被平台绑住。可观测性的完整设计见 可观测性。

有了结构化日志,就能回答三个复盘问题:哪一轮突然变贵、哪次工具失败、上下文是不是随轮次线性膨胀。能回答这三个,这个最小 Agent 就从"能跑"变成了"能解释、能优化"——而这正是后面 Research Agent 和 Multi-Agent App 要复用的底座。

持续学习,持续实践。