这两年我一直在折腾 AI Agent,说实话,最开始做第一版demo的时候,感觉这东西就是个"高级聊天机器人"——你问它什么它都能接,但让它去查个天气、算笔账、调个接口,它就傻眼了。后来我才彻底想明白一个道理:Agent 和 Chatbot 的本质区别,不在模型有多聪明,而在它有没有"手"。而让 Agent 长出这双手的关键,就是大模型接口层的设计和工具调用机制。
这篇是 ai-agent 教程系列的第三篇,我打算把"大模型接口"和"工具调用"这两个东西彻底掰开揉碎讲一遍。适合谁看?正在搭 Agent 框架、被 function calling 各种幺蛾子折磨的工程师,也包括想理解 Agent 工作原理、准备自己写一个最小实现的产品技术人。我会从接口层设计讲起,到工具调用的协议原理,再给一套可以直接抄作业的 Python 代码,最后把我踩过的坑逐个复盘。看完你应该能自己搭出一个稳得住的工具调用管线。
1. 接口层不是 HTTP 封装那么简单:Agent 的"通讯协议"地基
很多人一开始做 Agent,都是直接openai.ChatCompletion.create()一把梭。等你想换国产模型、想接入开源模型、想加缓存和重试的时候,就发现业务代码里全是某个厂商 SDK 的调用痕迹,改起来想死。这就是典型的"跳过接口层直接裸调"留下的债。
1.1 为什么大模型接口层值得单独设计
接口层在 Agent 架构里的地位,类似于操作系统的系统调用。它不是帮你省几行代码的 util 函数,而是整个对话管线上唯一稳定的锚点。你想想 Agent 跑一次任务要经历什么:多轮对话、工具调用、结果回填、再请求……这个过程里如果接口层不稳定,后面所有环节全是沙子上盖楼。
我在实际项目中是这样定义接口层职责的:
- 统一请求/响应格式:不管底层是 OpenAI、Claude、国产模型还是本地部署的 llama.cpp,Agent 业务侧只认一套消息结构和返回结构。
- 承载通用策略:重试、超时、熔断、token 统计、缓存,这些横切逻辑全部收口在接口层,而不是散落在每个业务调用处。
- 可观测性埋点:每一轮请求的耗时、token 消耗、tool_calls 内容,都是后续排查问题的重要依据。没有接口层,你连日志都不知道在哪打。
1.2 一个最小可靠的接口抽象长什么样
我推荐用一个非常朴素的complete()方法作为核心抽象,而不是去模仿那些重型 Agent 框架的复杂类继承体系:
@dataclass class LLMRequest: messages: list[dict] tools: list[dict] | None = None tool_choice: str | dict | None = None temperature: float = 0.7 max_tokens: int | None = None stream: bool = False @dataclass class LLMResponse: content: str | None tool_calls: list[dict] | None usage: dict | None raw: dict然后每个厂商实现一个 adapter,核心就是complete(req: LLMRequest) -> LLMResponse。什么chat.completions.create、messages.create这些 SDK 差异,全在 adapter 内部消化掉。
提示:这里有个容易犯的错——把
stream选项直接透传给所有厂商。不同厂商的流式事件格式完全不一样,adapter 里必须把它统一成同一个事件回调接口,否则业务侧一旦依赖了某厂商的流式 chunk 结构,换模型时又是一场灾难。
我见过很多人偷懒,把接口层的返回值设计成"直接返回模型输出的字符串"。这在纯聊天场景没问题,但一旦涉及工具调用,模型的输出是结构化的tool_calls列表而不是普通文本。所以从第一天起,返回结构里就必须预留tool_calls字段和content字段并存的可能。后面你会看到,这俩字段在工具调用流程里是要配合使用的。
1.3 为什么要预留 tool_choice 字段
这个字段看起来不起眼,但它是 Agent 可控性的关键之一。tool_choice能让你控制模型是否调用工具、以及强制调用哪个工具。我们在接口层把它暴露出来,是为了上层 Agent 编排逻辑能在不同阶段动态调整模型的"工具使用倾向"——比如第一轮先让模型自由发挥,发现它需要数据时再强制切换到某个查询工具。
2. 工具调用原理:模型怎么"决定"调用哪个工具
聊工具调用之前,必须先破除一个误解:模型本身并不会"执行"你的工具代码。它做的事情是在生成文本的过程中,额外输出一段结构化的 JSON——也就是工具调用的参数。真正执行工具的是你的 Agent 代码。理解了这一点,后面所有排查思路都会清晰很多。
2.1 一次完整工具调用的四个阶段
我用大白话拆一下这个链路:
- 模型看工具说明书:你把工具的 JSON Schema 描述塞进请求里,模型根据你的描述"理解"有哪些工具、每个工具需要什么参数。
- 模型输出调用意图:如果模型认为当前需要获取外部数据,它会输出一个特殊的
tool_calls结构,里面包含工具名和参数 JSON。 - Agent 执行真实工具:你的代码解析
tool_calls,找到对应的 Python 函数,把参数传进去执行,拿到结果。 - 结果回填再请求:把工具执行结果作为一条
tool消息追加到对话历史里,再次请求模型。模型看到结果后,要么继续调用下一个工具,要么给出最终回答。
注意:步骤 3 和 4 是你自己实现的,模型在这个过程中唯一做的事情是"产出结构化指令"。所以工具调用的可靠性,一半看模型理解能力,一半看你的执行和回填逻辑写得对不对。
2.2 关键消息结构:assistant 与 tool 的配对关系
各家厂商协议虽然细节不同,但核心消息结构高度相似。以最通用的格式为例,模型返回带工具调用的assistant消息后,你回填工具结果时必须是tool消息,并且要带上对应的tool_call_id:
// 模型返回 { "role": "assistant", "content": null, "tool_calls": [ { "id": "call_abc123", "type": "function", "function": { "name": "get_weather", "arguments": "{\"city\": \"北京\"}" } } ] } // 你回填的结果(messages 数组里追加) { "role": "tool", "tool_call_id": "call_abc123", "content": "北京,晴,25度" }这里有三个特别容易踩的坑:
content和tool_calls可以同时存在,也可以一方为 null,解析时不能假设 content 一定有值。tool_call_id必须严格回填,这是模型把工具结果和调用请求关联起来的唯一凭证。丢了它或写错了,不少模型会报错或直接胡言乱语。- 多个工具并行调用时,
messages里要按相同顺序追加多条tool消息,顺序错乱会导致模型把工具结果对应错位。
2.3 各家 API 的工具调用差异对比
我做接口层时把主流协议都摸了一遍,整理如下,方便你做 adapter 时心里有数:
| 项目 | OpenAI 系 | Anthropic 系 | 开源/兼容系 |
|---|---|---|---|
| 字段名 | tools | tools | 多为tools,兼容 OpenAI 格式 |
| 工具返回消息角色 | tool | user(带 tool_result 块) | 多为tool |
| 调用关联字段 | tool_call_id | tool_use_id | 多为tool_call_id |
| 并行调用 | 支持(parallel_tool_calls) | 支持 | 视实现而定 |
| 强制指定工具 | tool_choice: {"type": "function", "function": {"name": "xxx"}} | tool_choice: {"type": "tool", "name": "xxx"} | 杂 |
最烦人的是 Anthropic 的 tool_result 格式,它不是平铺的role字段,而是要嵌在content数组里用tool_result块包一层:
{ "role": "user", "content": [ { "type": "tool_result", "tool_use_id": "toolu_123", "content": "北京,晴,25度" } ] }如果你的 Agent 要接多厂商,这个差异必须收口在 adapter 内部,业务侧统一处理通用消息结构。
2.4 tool_choice 的玩法与陷阱
tool_choice是控制模型行为的开关,我按使用频率排一下:
"auto"(默认):模型自己判断要不要调工具。适合开放式任务,比如"帮我安排明天行程",模型可能先问清楚需求再动手。"none":禁止调用工具。适合纯文本问答或你已经确定它不需要工具的场景,能省大量 token。{"type": "function", "function": {"name": "xxx"}}:强制调用指定工具。适合路由明确的场景,比如用户明确说"查一下北京的天气"。"required":强制调用某个工具,但不指定具体是哪个(注:此写法在不同厂商中不一定支持,我在某些开源模型上见过直接报错的情况,用之前最好看过模型文档)。
我的经验是:能显式指定就显式指定。auto模式在复杂任务里太飘,模型经常该调不调、不该调乱调。强制指定后,虽然逻辑死板一点,但可控性大幅提升。后面调优技巧里我会细说。
3. 从零搭一个工具调用循环:注册表、Schema 与主循环
原理讲完了,上实操。这一节我给出一个最小可用的 Agent 工具调用循环,代码可以直接抄,然后一步步说清楚每个设计选择的原因。
3.1 先做工具注册表:集中管理,方便复用
写代码之前先把架构想清楚。我不推荐在每个业务文件里零星定义工具函数,而是做一个统一的注册表,让所有工具集中登记。这样 Agent 编排时能枚举工具清单,也能统一做权限控制和数据校验。
# tool_registry.py from typing import Callable, Any import inspect, json class ToolRegistry: def __init__(self): self._tools: dict[str, dict] = {} self._funcs: dict[str, Callable] = {} def register(self, name: str, description: str, schema: dict): def decorator(func: Callable): self._tools[name] = { "type": "function", "function": { "name": name, "description": description, "parameters": schema, }, } self._funcs[name] = func return func return decorator def get_schemas(self) -> list[dict]: return list(self._tools.values()) def call(self, name: str, arguments: dict) -> str: func = self._funcs[name] return json.dumps(func(**arguments), ensure_ascii=False)为什么要把返回值变成 JSON 字符串?因为模型接收的 assistant/tool 消息内容都是字符串,统一转换成 JSON 字符串可以避免类型不一致导致的解析问题。
3.2 把函数签名翻译成 JSON Schema:模型的"使用说明书"
模型是根据 JSON Schema 来理解"你希望它怎么调用工具"的。Schema 写得越清楚,模型参数填得越准。我的经验是:每个参数都要写详细的 description,包括枚举值、格式、默认行为,甚至可以给出示例。
weather_schema = { "type": "object", "properties": { "city": { "type": "string", "description": "城市名称,如北京、上海。必须是中文城市名。", }, "unit": { "type": "string", "enum": ["celsius", "fahrenheit"], "description": "温度单位,默认 celsius。", }, }, "required": ["city"], "additionalProperties": False, }有几点值得注意:
required字段只放真正必须的参数,别把所有参数都设为必填,否则模型在信息不足时会直接摆烂不调用工具。additionalProperties: False能约束模型不要输出额外字段,特别是一些小模型经常画蛇添足。- 偶尔会出现模型传入了 schema 之外的参数,导致你的函数执行时报
TypeError。这时候不要慌,在调用时用inspect.signature过滤掉多余参数就行。
3.3 主循环:带最大轮数保护,防止无限循环
接下来是核心循环。我见过很多新手写的 Agent 主循环没有max_iterations,模型在任务复杂时会陷入"调工具 -> 看结果 -> 再调工具"的死循环,token 哗哗烧,半天停不下来。主循环一定加两个保命阀:最大轮数和单轮超时。
def run_agent(registry: ToolRegistry, llm_request_func, user_message: str, max_iterations=10): messages = [{"role": "user", "content": user_message}] for step in range(max_iterations): resp = llm_request_func( messages=messages, tools=registry.get_schemas(), tool_choice="auto", temperature=0.5, # Agent 任务建议低温度,确定性优先 ) if resp.tool_calls: # 先把 assistant 消息追加进历史,再追加工具结果 messages.append({ "role": "assistant", "content": resp.content or "", "tool_calls": resp.tool_calls, }) for tc in resp.tool_calls: func_name = tc["function"]["name"] try: args = json.loads(tc["function"]["arguments"]) except json.JSONDecodeError: args = {} result = registry.call(func_name, args) # 实际执行工具 messages.append({ "role": "tool", "tool_call_id": tc["id"], "content": result, }) # 执行完不直接返回,进入下一轮让模型总结 else: # 没有工具调用了,模型给出最终答复 return resp.content return "Agent 达到最大迭代次数,任务未完成"这里我加一个关键设计说明:无论模型返回了什么,每轮都要把 assistant 消息原样追加到 messages。有些人只追加工具结果、不追加 assistant 的 tool_calls 消息,不少模型会报"消息序列非法"或干脆忽略历史。这是最隐蔽的 bug 之一。
3.4 给工具执行加保险:超时、异常、幂等
注册表里的工具函数不要裸奔,我在call()里套了一个保险层:
import signal class ToolExecutionError(Exception): pass def call_with_timeout(func, timeout=10): def handler(signum, frame): raise ToolExecutionError("tool execution timeout") signal.signal(signal.SIGALRM, handler) signal.alarm(timeout) try: return func() finally: signal.alarm(0)注意:signal 在 Windows 上支持不完整,生产环境如果跑在 Windows 上,建议改用线程
ThreadPoolExecutor的future.result(timeout=N)来做超时控制。
工具返回内容里我要求所有函数都返回字符串或可 JSON 序列化对象。因为模型对超长、非结构化的工具结果是会"消化不良"的,尤其是那些几百页的报告,模型很容易看着看着就丢失上下文。对于超长工具结果,我还会做一个截断逻辑:超过 6000 字的内容先摘要,再传回模型。
4. 流式输出与并发工具:Agent 体验的临门一脚
工具调用循环能跑通之后,你会发现新的问题:Agent 干活的时候,用户盯着一片白屏等,体验非常糟糕。这时候就要上流式输出和并发工具调用。
4.1 为什么流式输出不是可选项,而是必选项
我们在评测环境里比对过:一个需要调用 3 个工具的 Agent 任务,非流式场景用户平均等待 15 秒,中间没有任何反馈;流式场景下用户能实时看到"正在查询 A 数据""正在计算 B 指标"这类中间状态,体感等待时间大幅下降。
更重要的是,流式是 Agent 与用户建立信任的手段。如果你让用户盯着空白界面 15 秒,他很容易怀疑系统卡死了。流式输出配合状态提示,用户能感知到每一步进展。
实现上,接口层的 stream 参数要透传到 adapter,并统一成on_token回调:
def complete_stream(req: LLMRequest, on_token: Callable[[str], None]) -> LLMResponse: # 各厂商 stream 实现不同,这里统一在 adapter 内部处理 pass业务侧只管收增量文本,谁管你是 OpenAI 的delta.content还是 Anthropic 的content_block_delta?
4.2 流式场景下 tool_calls 的增量拼装
流式实现里最坑的是 tool_calls 不是一次性返回的。模型的工具调用参数是逐个 token 吐出来的,你需要从零开始拼装完整的 JSON。
OpenAI 系流式事件是delta.tool_calls数组,里面每个元素的index表示第几个工具调用,function.arguments可能只有一小段字符串。你需要按 index 累积参数片段,最后完整拼出来:
tool_calls_buffer: dict[int, dict] = {} for chunk in stream_events: if chunk.delta.tool_calls: for tc_delta in chunk.delta.tool_calls: idx = tc_delta.index if idx not in tool_calls_buffer: tool_calls_buffer[idx] = {"id": tc_delta.id or "", "name": "", "arguments": ""} if tc_delta.function: tool_calls_buffer[idx]["name"] += tc_delta.function.name or "" tool_calls_buffer[idx]["arguments"] += tc_delta.function.arguments or ""这里有个细节:id只会在第一个 chunk 里出现,后面的流式增量通常没有 id,所以要做空值保护。等流结束,再json.loads拼好的 args。如果你的工具参数很长,模型偶尔会输出截断的 JSON,解析会失败。我的兜底方案是解析失败时直接报错,让重试机制换一种方式重新请求。
4.3 多个工具并行调用的装配顺序
现在模型普遍支持一次回复里带多个 tool_calls。并行调用能显著提速,比如同时查天气和查航班,两个工具可以各跑各的。但要注意的是,你追加 tool 消息的顺序必须和 tool_calls 数组顺序保持一致。我在项目里踩过一次:代码用了asyncio.gather,返回结果顺序不稳定,结果工具结果张冠李戴,模型直接开始一本正经胡说八道。
解法是给每个并发任务用一个 dict 标记好index -> result,最后按原始 tool_calls 的 index 顺序组装消息。这块没什么黑魔法,就是细心。
5. 工具调用实战踩坑:现象、定位与修复全记录
最后一部分,我把这半年多来被工具调用折腾得最惨的几个问题完整复盘一遍。每个坑我都会描述现象、给出我的定位链路,最后是修复方案,希望能帮你少走几天弯路。
5.1 模型死活不调用工具,老给你硬编答案
现象:用户问"北京现在多少度",Agent 不调天气工具,直接回"I'm sorry, I cannot access real-time weather data"。
定位过程:我一开始怀疑是 schema 写得有问题,结果单独请求模型发现它能正常返回 tool_calls。后来我才意识到是temperature 设太高了。模型在温度高时更倾向于自由发挥而不是遵循预设的调用规则。我把温度从 1.0 调到 0.3 后,症状缓解了很多,但还是偶尔犯。
根因:模型是在"回答用户"和"调用工具"之间做权衡。如果工具描述和任务关联不强,模型会觉得直接泛泛回答更"安全"。你需要在工具的 description 里把触发条件写得更机械一点。比如天气预报工具的 description 改成:"当用户询问任何城市的天气、温度、降水情况时,必须调用此工具获取实时数据,禁止直接编造天气信息。"
修复:双管齐下——低温度 + 强触发描述 +tool_choice显式指定。如果任务目标明确,直接在请求里强制指定工具,比让模型猜靠谱一百倍。
5.2 模型连续用同一个工具刷屏,陷入工具死循环
现象:日志里显示同一工具被调用了 8 次,参数完全一样,模型还在一本正经地"精进计算"。
定位过程:我把每轮的 messages 历史打出来看,发现模型每次都在重复请求相同参数。为什么会重复?因为工具结果回填后,模型看到的是一堆相同的数据,它不知道该干嘛,只能凭着「还没有得到最终答案」的惯性继续调用。
根因:多数是任务指令缺失,没有告诉模型"什么时候该收手"。也可能是工具结果里没有包含模型能判断"任务已完成"的信息。
修复:两个手段。第一,在系统提示词里写清楚终止条件:"当你有足够信息回答用户时,必须停止调用工具。"第二,在工具结果里给一个隐藏信号,例如{"data": "...", "done": true},让模型看到done: true就不继续调用了。如果轮数还是超,就触发备用回复。
对"系统提示词"的严谨说明:使用系统提示词引导模型行为是常见做法,但不同模型对系统提示词的遵循程度差异很大,生产环境还是得靠
max_iterations这类硬限制兜底。
5.3 工具参数类型错误,明明传了字符串却要数字
现象:工具函数定义count: int,模型传了个"3次"进来,直接报错。
定位过程:打印模型传的原始参数,发现 JSON 解析后是"3次"而不是3。这其实是模型对 schema 的type: integer理解不到位,或者它被用户的自然语言表述带偏了。
根因:模型对 JSON Schema 的遵循能力和模型大小有直接关系,小模型经常不严格按照 schema 来。另外description里如果写了中文的"次"字,模型就容易把单位混进参数里。
修复:用 Pydantic 做输入校验和类型转换,别裸传参数给函数。Pydantic 能自动把能转换的值转成对应类型,解析不了的返回带明确字段名的错误提示,再回传给模型让它可以自我纠正:
from pydantic import BaseModel class WeatherParams(BaseModel): city: str unit: str = "celsius" # 在注册表里改为 model_validate try: p = WeatherParams.model_validate(args) except ValidationError as e: return f"参数错误: {e.errors()},请重新调用工具"把校验错误信息直接回传给模型,模型通常下一轮就会乖乖修正参数。这其实就是"模型自我纠错"的一个典型场景。
5.4 token 预算暴涨:工具结果和对话历史越滚越大
现象:一个 20 轮的工具调用任务,最后一轮请求的 prompt token 是初始的几十倍。
定位过程:一查发现 messages 长度每轮都在涨,而且每轮都带完整的 tools schema。tools schema 本身就有几百 token,如果每个工具 description 写得冗长,schema 可能占上千 token。再加上工具结果、assistant tool_calls 消息,整段对话越来越重。
修复:做三层优化。第一,tools schema 只在第一轮带,如果模型上下文支持,后续轮次可以复用同样的 tools(当然大多数 API 每次请求都要带上,可以通过支持的 tools 集合精简)。第二,对工具结果做摘要截断,只保留任务相关的关键字段。第三,设置对话历史窗口,超长时用滑动窗口把最早的消息压缩或删除。为什么删掉最老的?因为工具调用的上下文关联往往集中在最近几轮。
注意:不同厂商对"持续返回相同 tools 是否消耗重复 token"策略不同,实际优化时以你对接的模型账单为准。如果 token 消耗不是瓶颈,优先保证可靠性,别为了省 token 把上下文削坏了。
5.5 多轮工具调用之间的"记忆断层"
现象:第一轮查了天气,第二轮查了交通,第三轮问"那明天呢",模型答不上来,因为历史里没有显式的"明天"关联。
定位过程:观察 messages,发现我记得要求 Agent 把所有工具调用都追加进历史,但模型的上下文窗口有限,早期消息被挤掉了。模型看到第三轮时,"明天"失去了解释锚点。
修复:在每轮请求前,动态生成一个"会话快照"拼接进 prompt 里?不,我的做法更朴素——如果某个 Agent 任务明确是多轮依赖的,就把关键中间结果提取成短摘要常驻在系统提示词里;如果任务是单发的,干脆每轮只传最近 N 轮消息。这些策略可以在接口层做,不污染业务代码。
写在最后的实操体感
如果你从头搭到现在,会发现 Agent 工具调用的难点根本不在于"把请求发出去、拿到结果",而在于管线里的无数细节:消息配对、参数校验、循环终止、token 控制。我奉劝一句:刚开始别急着上重型框架,自己把最小循环写完,踩一遍上面的坑,你才能真正理解为什么那些框架要做那些抽象。
最后给你留一个可以立马上手的小作业:用 10 行以内的代码实现一个"强制调用单一工具"的 Agent,再扩展成"可选择多个工具",最后加上最大轮数限制。等你走完这三步,本篇文章的核心内容你就全部消化了。
做 Agent 这条路,接口层和工具调用是最不性感但最决定下限的部分。希望这篇能帮你把地基打扎实,下一步我们聊聊 Agent 的规划能力和记忆机制。