AI AGENT 开发 · 从入门到实战 · #05

工具调用实战:从零写一个会自己干活的 Agent(Python)

2026-09-17 QBnewb 约 9 分钟读完 约 3100 字
TL;DR
  • Function Calling 里模型只「点菜」不「下厨」:它输出结构化的调用意图,执行权永远在你的代码手里。
  • description 是调用成功率的第一杠杆,要按 API 文档写、过「实习生测试」;开 strict 模式等于白拿一层输出校验。
  • 并行调用默认开启:一轮可能来多个 tool_calls,先全部执行再统一回填,按 tool_call_id 一一配对,漏一个就报错。
  • 危险操作加确认门控:写/删/花钱/外发四类进白名单;用户拒绝也要回填给模型,让它改道而不是卡死。

上篇回顾:上一篇管住了上下文这笔「预算」。至此零件全部到齐——本篇总装:把 JSON Schema、并行调用、确认门控拧成一台真能干活的机器,全程可运行的 Python,百行以内。

一、模型只点菜,不下厨

先把最容易误解的一件事钉死:Function Calling(Function Calling,函数调用)里,模型从头到尾没有执行任何函数。官方时序是五步:你带着工具清单发请求 → 模型返回 tool call(结构化的调用意图)→ 你的代码执行对应函数 → 你把结果回填、再次请求 → 模型给出最终回答,或者继续发起调用 [1]。Anthropic 的表述异曲同工:模型返回「一个结构化调用,由你的应用执行」[2]。

模型是点菜的客人,菜单是 JSON Schema,后厨是你的代码。这个分工决定了两件事:第一,一切业务逻辑只该住在工具函数里,循环体只保留「发请求 → 解析 → 执行 → 回填」四个动作(第 1 篇的骨架);第二,assistant 消息必须原样入史——它记录着「模型发起过哪些调用」,丢了它,下一轮模型就忘了自己点过什么菜,回填也会因缺上下文而报错。

「执行权在代码手里」这件事,值得再展开讲,因为它带来三层实际好处。一是安全:模型永远碰不到你的生产系统,它递上来的只是一张写着参数的纸条,批不批、怎么执行,都是你的代码说了算——这也是第 5 节确认门控能存在的前提。二是可控:执行前后你都可以插审计日志、限流、灰度开关,而模型对此毫无感知。三是可换:工具层与模型解耦,换模型只改请求那一行——不过别忘了第 2 篇的提醒,换模型后 schema 的措辞要重新过一遍评测,不是免费复用。

二、说明书要写成 API 文档:JSON Schema 细节

菜单的质量决定点菜的质量。一个工具定义由五个字段组成:type(恒为 function)、name、description(何时用、怎么用)、parameters(JSON Schema,含 properties 与 required)、以及 strict;Anthropic 侧还支持给工具加输入示例(input_examples)[3]。其中最值得较真的是三条:

  • description 是调用成功率的第一杠杆。Anthropic 的原话:要提供「极其详细的描述,这是迄今影响工具表现最重要的因素」,建议每个工具至少三到四句——做什么、什么时候用、什么时候不用、每个参数什么含义、有没有副作用 [3][6]。OpenAI 给了一个极好的自检,叫「实习生测试」(pass the intern test):只把你喂给模型的信息交给一个新来的实习生,他能不能用对?[1];
  • 用 enum 和结构防呆。官方给的例子很形象:toggle_light(on: bool, off: bool) 允许了「同时开和关」的非法状态——换成单个带枚举的参数,非法状态就不存在了;开 strict: true 后调用「可靠地遵循 schema 而不是尽力而为」,官方建议常开,代价是每个对象要写 additionalProperties: false、所有字段进 required(可选项用可空类型表达)[1];
  • 工具清单是常驻开销。工具定义会被注入每一轮请求、计入上下文与账单,官方建议一开始少于 20 个工具 [1]——这正是第 2 篇「少而精」军规的账面依据。

顺手列三个我踩过的 schema 坑。其一,参数堆砌:把十个可选参数全塞进 properties,模型的选择困难症立刻发作——能在代码里推导的参数,就别让模型传。其二,布尔陷阱:前文 toggle_light(on, off) 的例子反过来用也成立,凡是「两个布尔不可能同时为真」的设计,都该合并成一个枚举。其三,格式自由:日期、金额这类强格式参数,要在 description 里写死格式约定(比如 ISO 8601 的 YYYY-MM-DD),否则模型今天是「9月1日」、明天是「2026/9/1」,你的解析代码迟早遭殃。schema 不是越详细越好,是越无歧义越好。

再补一个实战视角:这份 schema 一鱼两吃——它既是写给模型看的说明书,也是你代码里的调用契约。总装代码里 TOOLS[call.function.name](**args) 这一行,等于把模型按 schema 产出的参数原样拆给了 Python 函数;所以函数签名和 schema 的 properties/required 必须同步修改,否则就是运行时才炸的隐形契约。我的习惯是把 schema 定义写在对应工具函数的正下方,改哪个就一起改,review 时它们永远出现在同一个屏幕里。另外,Anthropic 还支持给工具附输入示例(input_examples),等价于给菜单配上「本店招牌吃法」,对复杂参数尤其值得 [3]。

三、总装:一台真能干活的机器

下面是本篇的主菜:一台带确认门控的笔记 Agent。三个工具(列目录、读笔记、写报告),其中「写」在确认白名单里——模型想落盘,必须先过你这一关。完整可运行:

import json
from pathlib import Path
from openai import OpenAI   # pip install openai(>=1.x);需先设置环境变量 OPENAI_API_KEY

client = OpenAI()

# —— 工具实现:业务逻辑只住在这里,循环里不写任何业务 ——
def list_notes() -> str:
    """列出 notes/ 目录下所有 .txt 笔记的文件名,每行一个。"""
    names = sorted(p.name for p in Path("notes").glob("*.txt"))
    return "\n".join(names) if names else "(空目录)"

def read_note(note_name: str) -> str:
    """按文件名读取单篇笔记,返回前 1500 字。"""
    path = Path("notes") / Path(note_name).name   # 防呆:只取文件名部分
    return path.read_text(encoding="utf-8")[:1500]

def write_report(title: str, content: str) -> str:
    """把报告保存为 notes/ 下的 Markdown 文件,返回确认信息。"""
    title = Path(title).name                      # 防呆:与 read_note 同款,只取文件名
    path = Path("notes") / f"{title}.md"
    path.write_text(content, encoding="utf-8")
    return f"已写入 {path}(共 {len(content)} 字符)"

TOOLS = {"list_notes": list_notes, "read_note": read_note, "write_report": write_report}
NEEDS_CONFIRM = {"write_report"}                 # 确认白名单:有副作用的操作

def ask_user(name: str, args: dict) -> bool:
    """人工确认:终端示例用 input;生产里换成审批系统或 IM 机器人。"""
    print(f"\n[需确认] {name}({json.dumps(args, ensure_ascii=False)})")
    return input("批准执行吗?(y/n) ").strip().lower() == "y"

SCHEMAS = [
    {"type": "function", "function": {
        "name": "list_notes",
        "description": "列出 notes/ 目录下所有 .txt 笔记的文件名。"
                      "在用户问『有哪些笔记』或需要挑文件阅读之前调用。",
        "parameters": {"type": "object", "properties": {}, "required": []}}},
    {"type": "function", "function": {
        "name": "read_note",
        "description": "按文件名读取单篇笔记的正文,返回前 1500 字。"
                      "note_name 必须来自 list_notes 的返回,不要自己编造文件名。",
        "parameters": {"type": "object",
                       "properties": {"note_name": {"type": "string",
                                                    "description": "笔记文件名,如 2026-09-01.txt"}},
                       "required": ["note_name"]}}},
    {"type": "function", "function": {
        "name": "write_report",
        "description": "把最终报告保存为 notes/{title}.md。属于写入操作,"
                      "调用前必须先获得用户确认;标题不要包含路径分隔符(代码也会兜底剥离路径部分)。",
        "parameters": {"type": "object",
                       "properties": {"title": {"type": "string", "description": "报告标题,同时作为文件名"},
                                      "content": {"type": "string", "description": "报告全文(Markdown)"}},
                       "required": ["title", "content"]}}},
]

def run(goal: str, max_steps: int = 10) -> str:
    messages = [
        {"role": "system", "content": "你是笔记助理。先弄清需求再动手;"
                                      "写文件前必须走确认流程,被拒绝就改用只读方式并说明原因。"},
        {"role": "user", "content": goal},
    ]
    for _ in range(max_steps):
        msg = client.chat.completions.create(
            model="gpt-4o-mini", messages=messages, tools=SCHEMAS
        ).choices[0].message
        if not msg.tool_calls:
            return msg.content
        messages.append(msg)                       # assistant 原样入史:记着它点过什么菜
        for call in msg.tool_calls:
            try:
                args = json.loads(call.function.arguments)   # 非法 JSON 也走回填,不炸循环
                if call.function.name in NEEDS_CONFIRM and not ask_user(call.function.name, args):
                    result = "用户拒绝了该操作;请改用只读方式,或调整方案后再征询。"
                else:
                    result = TOOLS[call.function.name](**args)
            except Exception as e:              # 错误回填:给模型改错的机会(第 3 篇)
                result = f"ERROR: {type(e).__name__}: {e}"
            messages.append({"role": "tool", "tool_call_id": call.id, "content": str(result)})
    return "已达步数上限,任务未完成。"

# 预期输出:终端先打印读取轨迹;写到 report.md 前弹出「[需确认]」等待 y/n;
#           批准后返回一段汇总答复,notes/report.md 落盘;拒绝则模型改道说明
print(run("看看 notes/ 里有什么,挑最长的一篇读一遍,把摘要写成 report.md"))

数一下,这台机器不到一百行,却已经具备生产 Agent 的三个标志:工具实现与循环彻底分离(业务只在 TOOLS 字典后面)、错误不炸机而是回填、危险动作先过人。在往下讲细节之前,建议你在脑内把它跑一遍——假设 notes/ 里有一篇 2026-09-01.txt:第一轮,模型看到目标与三个工具,决定先 list_notes;第二轮,它从清单里挑名字调 read_note(如果它编了一个不存在的名字,read_note 会抛 FileNotFoundError,错误回填后它会自己纠正);第三轮,它整理出摘要,发起 write_report——这一步命中确认白名单,你的终端弹出「[需确认]」并停下等待;输入 y,文件落盘,结果回填;第四轮,模型看到「已写入」,给出最终答复。整个过程中,模型只做过一件事:递纸条。

四、并行调用与错误回填

两个厂商的默认行为一致:模型可以在一轮里发起多个工具调用。OpenAI 允许用 parallel_tool_calls=false 关掉,关了就保证一轮至多一个调用 [1];Anthropic 用 disable_parallel_tool_use=true 达到同样效果 [3]。所以官方建议直接写进纪律里:按「一轮可能有多个调用」来写解析代码——上面总装代码的 for call in msg.tool_calls 就是这么来的。

并行的正确姿势是「先全部执行,再统一回填」。互不依赖的只读调用(多路搜索、多文件读取)适合并行;有先后依赖、有写副作用、需要确认的操作必须串行——稳妥做法是整批检查:一批里只要有一个需要确认,整批都等人批完再跑,避免「批了一个、执行了三个」。Anthropic 的格式规则天然帮了你一把:所有结果必须集中在紧随其后的那条消息里、按 id 配对,漏一个就直接 400 [3]:

# 并行批次示意(放在 async def 内):一轮多个 tool_calls——先全部执行,再统一回填
# 前置:import asyncio;dispatch(call) 封装单调用执行,含确认门控与异常捕获,返回字符串
# 整批预检:批次中只要有一个需确认的操作,整批等确认后再执行,避免「批一个、执行三个」
# 伪代码:if any(needs_confirm(c) for c in msg.tool_calls): 先走确认,再 gather()
results = await asyncio.gather(*[dispatch(call) for call in msg.tool_calls])
for call, result in zip(msg.tool_calls, results):   # 配对回填:tool_call_id 一个都不能少
    messages.append({"role": "tool", "tool_call_id": call.id, "content": result})

哪些调用值得并行?我的判断标准只有一条:调用之间除「互不依赖」外没有任何关系。多路搜索、多文件读取、对多个对象的同构查询,天生适合一批发出;凡是「后一步的参数取决于前一步的结果」,或者涉及写入与确认,老老实实串行。另有一个折中技巧:像「先查清单、再逐个读详情」这类两段式任务,让模型在第二轮一次性发出全部读调用——清单给了它批量并行的机会,一轮顶 N 轮。

错误回填方面,两家细节略有差异:Anthropic 的结果块有显式的 is_error 标志,SDK 会捕获异常、只回填异常消息不带堆栈,且默认把工具错误传回给模型让其自行应对 [3];OpenAI 没有专用标志,错误就是普通字符串回填,模型自行解读 [1]。还有一条容易忽略:返回值一律是字符串,连「没有返回值」的动作也要回一句 "success",别给模型留空白 [1]。最后一个工程建议:调试期先把并行关掉。并行批次里混着多个调用,轨迹读起来是交错的,行为也不易复现;先用「一轮一调用」把链路调通,再打开并行换性能——顺序别反。

五、确认门控:让危险操作先过人

哪些操作该进确认白名单?给一个我自己在用的四分类:写与删(尤其删除)、花钱(退款、下单、付费调用)、外发(邮件、发帖、调第三方接口)、批量操作。只读类(搜索、读取、计算)走自动放行。《Building Effective Agents》把人工确认列为基本护栏之一:人类在执行前介入批准,适用于高影响或不可逆的动作 [5]——我们的 NEEDS_CONFIRM 集合就是它最朴素的实现。

要做到生产级,可以照抄 Claude Agent SDK 的权限模型:每次工具调用按「Hooks → deny 规则 → 权限模式 → allow 规则 → canUseTool 回调」的顺序评估,deny 命中即拦(连旁路模式下也拦);权限模式分 default(未匹配则询问)、dontAsk(不问直接拒)、acceptEdits(自动放行文件编辑类)、plan(只规划不执行)等几档 [4]。注意那个反直觉的细节:拒绝不是终点——总装代码里被拒后回填的是「用户拒绝了该操作;请改用只读方式」,模型会体面地改道,而不是对着一个报错原地打转。门控的目的从来不是阻止 Agent 干活,而是让它在正确的轨道上干活。

确认交互本身也有讲究,三个细节决定它烦不烦人:第一,预览要可读——把工具名和参数用 json.dumps(args, ensure_ascii=False) 展示出来(总装代码就是这么写的),让批准者一眼看到「它到底要写什么」,而不是只看到一个函数名;第二,留痕——每次批准与拒绝都该进审计日志,谁、何时、批了什么,这是出事时唯一能救你的东西;第三,记住选择——同一会话里用户批准过「写 notes/ 目录」,后续同类操作可以合并询问,把打断频率压到必要最低。确认是稀缺注意力,要花在真正的刀刃上。


小结

  • Function Calling 的分工:模型输出意图,你的代码执行;assistant 消息原样入史,是回填与多轮的命根;
  • Schema 按 API 文档写:description 过「实习生测试」,enum + strict 白拿校验,工具清单控制在 20 个以内;
  • 并行默认开启:先全部执行、再统一回填,tool_call_id 一一配对;错误与拒绝都要回填,给模型改道的机会;
  • 确认门控四分类:写删、花钱、外发、批量;只读自动放行——门控不是刹车,是方向盘。

下一篇预告:这台机器的工具是我们手搓的,可工具生态千千万,难道每个都要自己写一遍 schema?下一篇《MCP 入门》讲工具生态的「USB-C」:一个开放协议,让 Agent 即插即用地接入全世界的工具。

参考来源

  1. Function calling, OpenAI API 文档, 2026,developers.openai.com
  2. Tool use with Claude, Anthropic, 2026,platform.claude.com
  3. How to implement tool use, Anthropic, 2026,platform.claude.com
  4. Configure permissions — Claude Agent SDK, Anthropic, 2026,platform.claude.com
  5. Building Effective Agents, Anthropic, 2024-2025,anthropic.com
  6. Writing Tools for Agents, Anthropic Engineering, 2025,anthropic.com
Q
QBnewb
相信「AI 是杠杆」的独立开发者。这个博客由多智能体流水线调研、写作、评审与排版——用 AI 做内容,也是 AI Agent 实战的一部分。