☰
Agent工具调用引擎:从Function Calling到生产级落地的完整拆解
2026/10/28 1:33:43 网站建设 项目流程

Agent 工具调用引擎,说白了就是让大模型从"只会聊天"变成"能干活"的那层关键中间件。你给它一句"帮我查下明天北京的天气,如果下雨就提醒我带伞",它得自己判断该调哪个工具、参数怎么填、返回结果怎么塞回上下文、下一步还要不要继续调。这一整套调度逻辑,就是工具调用引擎要解决的问题。我前后在几个项目里手写过这层东西,也用过现成框架,踩过的坑不算少。这篇就把我理解的工具调用引擎从设计到落地完整拆一遍,包括 Function Calling 的协议细节、流式分片怎么拼、JSON Schema 怎么写才不翻车、并发和错误处理怎么做。不管你是刚开始接触 agent 开发,还是已经能跑通 demo 但一到生产就各种崩,这篇应该都能给你一些能直接抄的东西。

1. 工具调用引擎到底在解决什么问题

1.1 从"聊天"到"干活"的那道坎

大模型本身是个纯文本进、纯文本出的东西。你问它"现在几点",它只能根据训练数据瞎猜一个时间,因为它没有读取系统时钟的能力。这不是它笨,是它的能力边界就在那——它只能做"文本到文本"的映射,没法跟外部世界交互。

工具调用引擎要做的,就是在模型和外部世界之间架一座桥。模型说"我想调用 get_current_time 这个工具",引擎负责真正去执行这个函数,把结果拿回来,再喂给模型让它继续推理。整个过程对模型来说,它只是在生成一段特定格式的文本;对引擎来说,它是在解析这段文本、执行真实操作、再把结果拼回去。

我习惯把这个过程类比成"给一个特别聪明但被关在房间里的人配了个跑腿的"。房间里的人(模型)负责思考和决策,跑腿的(引擎)负责出门办事。房间里的那个人看不到外面,只能通过写纸条告诉跑腿的要去干什么,跑腿的办完事再把结果写成纸条递回去。工具调用引擎就是那个读纸条、跑腿、写回执的角色。

1.2 一次完整的工具调用要经过哪些环节

很多人以为工具调用就是"模型输出个函数名,我执行一下",实际跑起来远不止这么简单。一次完整的调用链路,我拆成这么几个阶段:

  • 工具注册与描述注入:把你的工具列表(名字、功能说明、参数 schema)转换成模型能理解的格式,塞进 system prompt 或专门的 tools 字段里。
  • 模型决策:模型根据用户输入和工具描述,判断要不要调工具、调哪个、参数填什么。
  • 调用意图解析:从模型输出里提取出结构化的调用请求(工具名 + 参数 JSON)。
  • 参数校验:检查模型给的参数是否符合 schema,类型对不对、必填项有没有缺。
  • 工具执行:真正调用你的函数/API,拿到结果。
  • 结果回填:把执行结果按协议格式塞回对话历史,触发模型下一轮推理。
  • 循环终止判断:模型不再请求工具、直接给出最终回答时,循环结束。

这七个环节里,任何一个出问题都会导致整个链路崩掉。我见过最多的翻车点是参数校验和结果回填这两步——模型给的参数经常有各种奇葩情况,而结果回填的格式一旦不对,模型下一轮就会彻底懵掉。

1.3 为什么不用现成框架而要理解底层

现在 LangChain、Dify、CrewAI 这些框架都能帮你把工具调用跑起来,几行代码就能出效果。但我还是建议你至少手写一遍这层引擎,原因有三个。

第一,框架帮你屏蔽了细节,但出问题的时候你根本不知道从哪查。我遇到过模型一直不调工具的情况,用框架完全看不出为什么,最后手写了一遍才发现是工具描述里的措辞让模型误判了。

第二,生产环境的需求框架往往满足不了。比如你要做流式输出、要做工具调用的细粒度超时控制、要做并发限流,这些框架要么不支持,要么支持得很别扭。

第三,理解底层之后你用框架会更顺。知道引擎在干什么,你就能判断框架的哪些设计是合理的、哪些是坑,选型的时候心里有数。

2. Function Calling 的协议细节与流式分片处理

2.1 Function Calling 的两种主流协议形态

目前主流的工具调用协议有两种形态,理解它们的区别对后面处理流式很关键。

一种是 OpenAI 风格的tools字段。你在请求里传一个 tools 数组,每个工具包含 name、description、parameters(JSON Schema 格式)。模型如果决定调用,会在响应里返回一个tool_calls数组,每个元素有 id、type、function.name、function.arguments。注意 arguments 是个 JSON 字符串,不是对象,需要你自己 parse。

另一种是部分模型用的特殊 token 或 XML 标签风格,模型直接在文本里输出类似<tool_call>{"name": "xxx", "arguments": {...}}</tool_call>这样的内容,你需要用正则或解析器把它抠出来。这种风格在开源模型里更常见,因为不依赖特定的 API 协议。

我建议你的引擎在设计上把"协议解析"这层抽象出来,做成可插拔的。因为不同模型、不同供应商的协议细节都不一样,硬编码一种协议后面换模型会非常痛苦。

2.2 流式场景下 arguments 为什么要分片

这是很多人第一次做流式工具调用时最懵的地方。非流式情况下,模型一次性返回完整的tool_calls,arguments 是个完整的 JSON 字符串,直接 parse 就行。但流式情况下,模型是一个 token 一个 token 往外吐的,arguments 会被切成很多片,分散在多个 chunk 里。

比如模型要生成{"city": "北京", "date": "2024-01-01"},流式返回可能是这样的:

chunk1: {"index":0,"function":{"name":"get_weather","arguments":"{\"ci"}} chunk2: {"index":0,"function":{"arguments":"ty\": \"北"}} chunk3: {"index":0,"function":{"arguments":"京\", \"da"}} chunk4: {"index":0,"function":{"arguments":"te\": \"2024-01-01\"}"}}

你要做的是把这些 arguments 片段按顺序拼接起来,拼完再 parse。这里有几个坑必须注意。

第一个坑是index字段。当模型可能同时调用多个工具时,每个 tool_call 有自己的 index,你必须按 index 分组拼接,不能一股脑全拼一起。

第二个坑是 name 字段可能只在第一个 chunk 出现。后面的 chunk 只有 arguments,没有 name。所以你要维护一个按 index 索引的状态表,记录每个 tool_call 的 id、name 和累积的 arguments。

第三个坑是 arguments 拼接完之前不能 parse。有些实现图省事,每收到一个 chunk 就尝试 parse 一次,结果当然是各种报错。必须等这个 tool_call 的所有分片都到齐(通常以 finish_reason 为tool_calls或流结束为标志)才能 parse。

2.3 一个能扛住流式分片的累积器实现

下面这个累积器的思路我用了好几个项目,比较稳。核心是维护一个 Map,key 是 tool_call 的 index,value 是累积状态。

class ToolCallAccumulator: def __init__(self): self.calls = {} # index -> {id, name, arguments} def feed(self, delta_tool_calls): for tc in delta_tool_calls: idx = tc.get("index", 0) if idx not in self.calls: self.calls[idx] = {"id": "", "name": "", "arguments": ""} entry = self.calls[idx] if tc.get("id"): entry["id"] = tc["id"] fn = tc.get("function", {}) if fn.get("name"): entry["name"] = fn["name"] if fn.get("arguments"): entry["arguments"] += fn["arguments"] def finalize(self): result = [] for idx in sorted(self.calls.keys()): entry = self.calls[idx] try: args = json.loads(entry["arguments"]) if entry["arguments"] else {} except json.JSONDecodeError: args = None # 标记为解析失败,走错误处理 result.append({ "id": entry["id"], "name": entry["name"], "arguments": args, "raw": entry["arguments"], }) return result

这里我特意保留了raw字段,因为当 JSON 解析失败时,你需要原始字符串来排查问题。生产环境里我见过模型生成出带尾随逗号、单引号、甚至中文引号的 JSON,这些都得靠 raw 来定位。

注意:finalize里对解析失败的处理不要直接抛异常,而是标记出来走降级逻辑。因为流式场景下偶发的解析失败很常见,直接崩掉整个对话体验很差。

2.4 流式输出与工具调用的顺序问题

还有一个容易被忽略的点:流式场景下,模型可能在输出一段文本之后才开始请求工具调用。比如用户问"帮我算下 3 加 5 等于几,顺便说句鼓励的话",模型可能先流式输出"好的,我来帮你算一下",然后才开始 tool_calls。

这意味着你的前端渲染逻辑要能处理"文本流 + 工具调用流"混合的情况。我的做法是给每个 chunk 打标记,文本 delta 走文本渲染通道,tool_calls delta 走累积器,两者互不干扰。等流结束后,如果累积器里有待执行的调用,就执行工具、回填结果、发起下一轮请求。

3. JSON Schema 怎么写才能让模型不翻车

3.1 工具描述的质量直接决定调用准确率

我做过一个粗略的统计,在同一个模型上,工具描述写得好的和写得烂的,调用准确率能差出 30% 以上。这不是夸张,模型判断调不调工具、调哪个工具,几乎完全依赖你给的描述。

一个合格的工具有三个部分必须写清楚:name(工具名)、description(功能说明)、parameters(参数 schema)。很多人只重视 parameters,把 description 随便写一句,结果模型经常在多个相似工具之间选错。

description 的写法我总结了几条经验:

  • 说清楚"什么时候用",而不只是"这是什么"。比如不要只写"查询天气",而要写"当用户询问某个城市的天气、温度、是否下雨时使用此工具"。
  • 明确边界。如果有两个工具功能相近,要在描述里写清楚各自的适用场景,避免模型混淆。
  • 参数说明要具体。不要写"date: 日期",而要写"date: 查询日期,格式为 YYYY-MM-DD,不传则默认今天"。

3.2 参数 schema 的常见写法与陷阱

JSON Schema 本身很灵活,但给模型用的 schema 要尽量简单明确。下面是一个我常用的天气工具 schema:

{ "type": "object", "properties": { "city": { "type": "string", "description": "城市名称,如'北京'、'上海',不要带'市'字" }, "date": { "type": "string", "description": "查询日期,格式 YYYY-MM-DD,不传默认今天" }, "unit": { "type": "string", "enum": ["celsius", "fahrenheit"], "description": "温度单位,默认摄氏度" } }, "required": ["city"] }

这里有几个陷阱要避开。

陷阱一:滥用 enum 但不给默认值。enum 能约束模型输出,但如果你不告诉模型默认选哪个,它可能随机选或者干脆不传。对于有默认行为的参数,要么在 description 里写明默认值,要么在代码里做兜底。

陷阱二:嵌套结构太深。模型对深层嵌套的对象处理能力有限,三层以上的嵌套经常出错。如果确实需要复杂结构,考虑拆成多个扁平工具。

陷阱三:required 字段太多。每多一个必填项,模型出错的概率就上升一点。能设默认值的参数就不要设成 required。

陷阱四:类型用 any 或省略 type。有些实现为了省事不写 type,结果模型可能传个字符串进来你期望的是数字。类型一定要写明确。

3.3 参数校验:不能全信模型

模型给的参数永远不能直接信。我踩过最惨的一次是模型给一个"数量"参数传了"三"而不是3,代码里直接拿去做乘法,整个流程崩了。

参数校验我建议分三层做:

第一层是结构校验,用 JSON Schema 校验库(Python 里可以用 jsonschema)检查类型、必填项、enum 范围。这层能挡掉大部分明显错误。

第二层是业务校验,比如日期格式对不对、城市名在不在你的支持列表里、数值在不在合理区间。这层需要你自己写逻辑。

第三层是兜底转换,对于能容错的参数做智能转换。比如模型传了"三",你可以尝试转成3;传了"2024/01/01",你可以尝试转成"2024-01-01"。这层能显著提升鲁棒性。

def validate_and_coerce(tool_name, args, schema): # 第一层:结构校验 try: jsonschema.validate(args, schema) except jsonschema.ValidationError as e: # 尝试兜底转换后再校验 args = coerce_types(args, schema) jsonschema.validate(args, schema) # 再失败就抛 # 第二层:业务校验 if tool_name == "get_weather": if "date" in args and not re.match(r"\d{4}-\d{2}-\d{2}", args["date"]): args["date"] = normalize_date(args["date"]) return args

3.4 工具数量多了怎么办

当你的工具超过 10 个,模型的选择准确率会明显下降。这时候有几个策略。

一是工具分组,把功能相近的工具归到一个"命名空间"下,让模型先选组再选具体工具。二是动态工具注入,根据用户当前意图只注入相关的几个工具,而不是把所有工具都塞进去。三是工具检索,把工具描述做向量化,用户输入来了先检索最相关的几个工具再注入。

我一般用第二种,实现简单效果也好。具体做法是用一个轻量分类器(甚至可以是规则)判断用户意图属于哪个领域,然后只注入该领域的工具。比如用户问天气,就只注入天气相关的工具,其他工具不出现,模型的选择空间小了,准确率自然上去。

4. 工具执行层的并发、超时与错误处理

4.1 多个工具并行调用的调度

模型有时候会一次性请求调用多个工具,比如"帮我查下北京和上海今天的天气"。这时候两个工具调用之间没有依赖关系,完全可以并行执行。

并行执行的关键是结果顺序要和请求顺序对应。模型返回的 tool_calls 是有顺序的,你回填结果时也必须按同样的顺序,否则模型会对应错。我的做法是用一个有序的结果数组,每个工具执行完把结果放到对应的位置。

async def execute_tools_parallel(tool_calls): tasks = [] for tc in tool_calls: tasks.append(execute_single_tool(tc)) results = await asyncio.gather(*tasks, return_exceptions=True) # results 的顺序和 tool_calls 一致 return results

这里用return_exceptions=True是为了让单个工具失败不影响其他工具。某个工具抛异常了,对应的结果位置放一个错误信息,其他工具正常返回。

4.2 超时控制:不能让一个慢工具拖垮整个对话

工具执行必须有超时。我见过一个案例,某个工具调用的第三方 API 挂了,请求一直挂着不返回,整个对话就卡死在那,用户等了两分钟直接关掉了。

超时控制要分层做。单工具超时是最基本的,每个工具执行设一个上限,比如 10 秒。整体超时是给整个工具执行阶段设一个上限,比如 30 秒,防止多个工具串行累加超时。对话级超时是给整个多轮对话设上限,防止模型陷入无限调用循环。

async def execute_with_timeout(tool_call, timeout=10): try: return await asyncio.wait_for( execute_single_tool(tool_call), timeout=timeout ) except asyncio.TimeoutError: return {"error": f"工具 {tool_call['name']} 执行超时"}

超时后的处理也有讲究。不要直接把超时当致命错误终止对话,而是把超时信息作为工具结果回填给模型,让模型自己决定是重试、换工具还是告诉用户。模型往往能给出合理的降级回答。

4.3 错误分类与重试策略

工具执行出错分好几种,处理方式完全不同。

参数错误(模型给的参数不合法):这种重试没用,因为模型大概率还会给同样的参数。正确做法是把错误信息回填给模型,让它重新生成参数。我一般允许模型重试一次,还不行就放弃。

临时性错误(网络抖动、限流):这种适合重试,但要带退避。我一般重试 2 次,间隔 1 秒、2 秒。

永久性错误(工具本身有 bug、依赖的服务下线):重试没意义,直接把错误回填给模型,让它告诉用户。

权限错误(用户没授权):这种要特殊处理,可能需要触发授权流程,而不是简单报错。

RETRYABLE_ERRORS = (ConnectionError, TimeoutError, RateLimitError) async def execute_with_retry(tool_call, max_retries=2): for attempt in range(max_retries + 1): try: return await execute_single_tool(tool_call) except RETRYABLE_ERRORS as e: if attempt == max_retries: return {"error": f"重试 {max_retries} 次后仍失败: {str(e)}"} await asyncio.sleep(2 ** attempt) except Exception as e: return {"error": f"工具执行失败: {str(e)}"}

4.4 防止无限调用循环

模型有时候会陷入"调工具 → 看结果 → 再调同一个工具"的死循环。我遇到过模型反复调用同一个查询工具,每次都拿到一样的结果,但它就是不停。

防护措施有几个。一是设置最大调用轮数,比如最多 10 轮,超过就强制终止并返回当前结果。二是检测重复调用,如果连续两次调用的工具名和参数完全一样,直接终止。三是在 system prompt 里明确告知模型"如果工具返回的结果已经足够回答用户问题,请直接给出回答,不要重复调用"。

MAX_ROUNDS = 10 seen_calls = set() for round in range(MAX_ROUNDS): response = await call_model(messages) if not response.tool_calls: break for tc in response.tool_calls: call_key = (tc["name"], json.dumps(tc["arguments"], sort_keys=True)) if call_key in seen_calls: # 重复调用,终止 return "检测到重复工具调用,已终止" seen_calls.add(call_key) # ... 执行工具、回填结果

5. 上下文管理与多轮工具调用的状态维护

5.1 工具结果怎么塞回对话历史

工具执行完,结果要按协议格式塞回 messages 数组,然后再次请求模型。不同协议的格式不一样,OpenAI 风格是加一条 role 为tool的消息,带上 tool_call_id 和 content。

messages.append({ "role": "assistant", "content": None, "tool_calls": original_tool_calls # 原样保留 }) for tc, result in zip(tool_calls, results): messages.append({ "role": "tool", "tool_call_id": tc["id"], "content": json.dumps(result, ensure_ascii=False) })

这里有个细节:assistant 那条消息必须原样保留模型的 tool_calls,不能省略。因为 tool 消息是通过 tool_call_id 和 assistant 消息关联的,如果 assistant 消息里没有对应的 tool_call,很多 API 会直接报错。

5.2 工具结果太长怎么办

有些工具返回的结果特别长,比如一个搜索工具返回了 5000 字的网页内容。直接塞回上下文会占用大量 token,而且模型可能被无关信息干扰。

我的处理策略是截断 + 摘要。对于结构化结果,只保留关键字段;对于文本结果,超过一定长度就截断,并在末尾加一句"(内容已截断)"。如果工具本身支持,可以让工具自己返回摘要版本。

还有一种做法是结果外置,把长结果存到一个临时存储,只在上下文里放一个引用 ID 和简短摘要,模型需要详情时再通过另一个工具去取。这个方案复杂一些,但对长结果场景很有效。

5.3 多轮对话中的工具状态

在多轮对话里,工具调用的状态需要跨轮维护。比如用户第一轮问"北京天气怎么样",第二轮问"那上海呢",模型需要知道第二轮是在延续第一轮的工具上下文。

这里的关键是完整保留历史 messages,包括之前所有的 tool_calls 和 tool 结果。不要为了省 token 把历史工具调用删掉,否则模型会丢失上下文。如果确实 token 紧张,可以对历史工具结果做摘要压缩,但 assistant 的 tool_calls 结构要保留。

我一般会维护一个对话级的 session 对象,里面存完整的 messages 历史、已调用过的工具记录、以及一些业务状态(比如用户当前查询的城市)。这样跨轮的时候状态不会丢。

5.4 并发场景下的会话隔离

如果你的服务要扛并发,会话隔离是必须的。每个用户的对话要有独立的 messages 历史、独立的工具调用状态、独立的超时计时。绝对不能用一个全局的 messages 数组,否则用户 A 的工具结果会串到用户 B 的对话里。

我的做法是每个会话分配一个 session_id,所有状态都存在以 session_id 为 key 的存储里。如果用内存存储,要注意清理过期会话,否则内存会涨。如果用 Redis 之类的,要注意序列化和并发读写的问题。

提示:并发场景下还有一个隐蔽的坑是工具的副作用。如果两个会话同时调用一个会修改共享状态的工具(比如扣库存),必须加锁或做幂等。工具本身的设计要考虑并发安全,不能假设串行执行。

6. 实测中那些文档不会告诉你的坑

6.1 模型不调工具的五种原因

模型该调工具却不调,是最常见也最让人抓狂的问题。我排查下来,原因基本逃不出这五种:

  • 工具描述太模糊,模型没意识到该用这个工具。解决方法是把 description 写得更具体,明确触发场景。
  • system prompt 里没强调要用工具。有些模型默认倾向于直接回答,你需要在 system prompt 里明确说"当需要实时信息或执行操作时,优先使用提供的工具"。
  • 工具名或参数名有歧义。比如两个工具都叫search,模型分不清。工具名要唯一且语义清晰。
  • 模型能力不够。小模型对工具调用的支持本来就弱,换个更强的模型可能就好了。
  • 协议格式不对。你注入工具的格式和模型期望的不一致,模型根本没识别出来。这个最隐蔽,建议先用官方示例跑通再改。

6.2 参数类型错误的典型模式

模型给参数类型出错,有几个高频模式我总结了一下:

错误模式例子处理方式
数字写成中文"三"而非3中文数字转阿拉伯数字
布尔写成字符串"true"而非true字符串转布尔
数组写成字符串"['a','b']"而非["a","b"]尝试 eval 或 json parse
日期格式不统一"2024/1/1"而非"2024-01-01"日期归一化
嵌套对象拍平{"a.b": 1}而非{"a":{"b":1}}按 schema 还原

这些转换逻辑我建议统一放在一个coerce_types函数里,按 schema 递归处理。写一次,所有工具都能用。

6.3 流式解析中的边界情况

流式解析的边界情况特别多,我列几个印象深刻的:

空 arguments:有些工具没有参数,模型返回的 arguments 是空字符串""。这时候json.loads("")会报错,要特殊处理成{}。

arguments 是 null:偶尔模型会返回arguments: null,也要当成{}。

分片跨 JSON 边界:分片可能正好切在 JSON 的引号或括号中间,拼接的时候要注意不要引入额外字符。

多个 tool_call 交错:当模型同时调多个工具时,不同 tool_call 的分片可能交错到达。必须严格按 index 分组,不能按到达顺序拼。

finish_reason 的判断:流结束时 finish_reason 可能是tool_calls、stop、length。只有tool_calls才表示模型要调工具,其他情况要区别处理。

6.4 工具执行的安全边界

工具执行意味着你的代码要真的去执行操作,这里的安全边界必须划清楚。

输入校验不能省。模型给的参数可能包含注入攻击的 payload,比如 SQL 注入、命令注入。所有参数在进入实际执行前必须做安全过滤。

权限控制要做。不是所有用户都能调用所有工具。比如删除数据的工具,只有管理员能用。这个权限判断要在引擎层做,不能依赖模型。

副作用工具要二次确认。对于会修改数据、发送消息、扣款这类有副作用的工具,建议在执行前让用户确认,而不是模型说调就调。

资源限制要设。工具执行可能消耗 CPU、内存、网络,要设上限。比如一个查询工具返回的数据量要限制,防止把内存撑爆。

6.5 性能优化的几个实操点

工具调用链路的性能瓶颈通常在两个地方:模型推理和工具执行。模型推理这块你能优化的有限,主要是减少上下文长度、用更快的模型。工具执行这块优化空间大。

并行化:无依赖的工具调用并行执行,这个前面说过。

缓存:对于幂等的查询类工具,相同参数的结果可以缓存。比如天气查询,同一个城市同一天的结果缓存几分钟没问题。

预热:对于启动慢的工具(比如要建立数据库连接),提前预热,避免第一次调用特别慢。

降级:工具不可用时要有降级方案。比如天气 API 挂了,可以返回一个"暂时无法获取天气"的友好提示,而不是直接报错。

7. 从 demo 到生产还差哪些东西

7.1 可观测性:没有日志等于瞎跑

demo 阶段你盯着控制台看就行,生产环境必须有一套完整的日志和监控。我至少要记录这些东西:每次工具调用的工具名、参数、执行耗时、结果状态(成功/失败/超时)、错误信息。这些日志要能按 session_id 串起来,方便排查单个用户的问题。

除了日志,还要有指标监控。工具调用成功率、平均耗时、超时率、参数校验失败率,这些指标能帮你快速发现异常。比如成功率突然掉到 80%,肯定是某个工具出问题了。

7.2 限流与配额

生产环境必须限流,否则一个用户疯狂调用能把你的资源耗光。限流要分维度做:用户级(每个用户每分钟最多调多少次)、工具级(某个昂贵工具全局每分钟最多调多少次)、会话级(单个会话最多多少轮工具调用)。

配额管理也很重要,特别是如果你的工具调用要花钱(比如调用付费 API)。要给每个用户设配额,用完就降级或提示。

7.3 灰度与回滚

工具调用引擎的改动风险很高,一个小改动可能导致所有对话崩掉。所以上线必须灰度,先放一小部分流量,观察指标正常再逐步放大。同时要有快速回滚机制,出问题能立刻切回旧版本。

我一般会把工具配置做成可热更新的,不用重启服务就能改工具描述、参数 schema、超时时间这些。这样出问题的时候能快速调整,不用走完整的发布流程。

7.4 测试策略

工具调用引擎的测试比普通代码难,因为涉及模型的不确定性。我的测试策略分三层:

单元测试:测参数校验、类型转换、流式累积器这些纯逻辑部分,这些是确定的,可以写完整的测试用例。

集成测试:用 mock 的模型响应测整个链路,覆盖各种边界情况(空参数、多工具、超时、错误)。

端到端测试:用真实模型跑一批典型场景,评估调用准确率。这个不能作为 CI 的硬性门槛(因为模型有随机性),但要定期跑,观察准确率趋势。

我还会维护一个"回归用例集",把线上遇到过的各种奇葩 case 收集起来,每次改动都跑一遍,防止改出新问题。

7.5 版本演进与兼容

工具是会变的,今天加个参数,明天改个返回值,后天废弃一个工具。这些变更要考虑兼容性。

我的做法是给工具加版本号,比如get_weather_v2。新版本上线后旧版本保留一段时间,等确认没有调用方依赖了再下线。同时要在工具描述里标注废弃信息,引导模型用新版本。

参数变更也要小心。给已有参数改类型是破坏性变更,会导致历史对话解析失败。如果非要改,加新参数而不是改老参数,老参数保留做兼容。

8. 一些个人体会

工具调用引擎这东西,看起来简单,做起来细节特别多。我最大的体会是:不要低估模型的不确定性,也不要高估自己的容错能力。你以为模型会规规矩矩给参数,实际上它什么奇葩都能给你整出来。你以为自己的代码能处理所有情况,实际上线上总有你没想过的 case。

所以我的原则是:每一层都做防御,每一个假设都加校验,每一个错误都有降级。宁可代码啰嗦一点,也不要让一个未处理的异常把整个对话搞崩。

另外一个体会是,工具描述值得反复打磨。我见过太多人把工具描述随便写写,然后抱怨模型调用不准。实际上你把描述改好,准确率能提升一大截。这个投入产出比非常高,值得花时间。

最后,别急着上框架。手写一遍引擎,你会对整个链路有完全不同的理解。之后再选框架,你会知道该看哪些点,该避开哪些坑。这个学习成本,长远看是值得的。

工具调用引擎的演进是没有终点的,模型在变、协议在变、业务需求在变,你的引擎也得跟着变。保持对细节的敏感,保持对边界情况的敬畏,这层东西就能越做越稳。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询