函数调用(tool use)的工程细节:schema、容错与循环
GreySuit
9 min read
函数调用让模型从"生成文本"变成"生成结构化动作"。但真正上线时,麻烦几乎都集中在三处:schema 定义是否可解析、模型输出出错怎么兜底、以及循环什么时候该停。这三件事处理不好,工具再强也会变成不稳定来源。
schema 是契约,不是文档
工具描述要写成机器能稳定消费的结构。常见错误是把"用途说明"写成长段自然语言,参数类型含糊。模型在解析时最容易出错的是:枚举值、必填项、嵌套对象。
{
"name": "query_orders",
"description": "按条件查询订单,返回订单列表",
"parameters": {
"type": "object",
"required": ["status"],
"properties": {
"status": {
"type": "string",
"enum": ["paid", "shipped", "refunded"],
"description": "订单状态,必须是指定值之一"
},
"limit": {
"type": "integer",
"minimum": 1,
"maximum": 100,
"default": 20
}
}
}
}
几条经验规则:
description 写"做什么"和"何时用",不要写"怎么实现"。实现细节是本地代码的事,模型不需要知道。
- 能给
enum 就别用自由字符串,把歧义挡在 schema 层。自由字符串留给真正无法枚举的字段。
- 数值参数务必带
minimum/maximum,否则模型会传 0 或负数,下游直接崩。
- 可选参数给
default,减少模型纠结,也减少缺失字段带来的分支处理。
- 嵌套对象的每一层都要声明
type 和 required,深层结构最容易被模型漏填。
schema 越严格,模型"自由发挥"的空间越小,出错概率越低。把不确定性尽量压到编译期(schema 校验)而不是运行期(业务报错)。
容错:模型输出一定会犯错
模型可能返回非法 JSON、漏掉必填字段、或把字符串塞进整数位。不要相信任何一次工具调用,先做本地校验:
from pydantic import BaseModel, ValidationError
class QueryOrdersArgs(BaseModel):
status: str
limit: int = 20
def parse_tool_call(raw: dict):
try:
args = QueryOrdersArgs(**raw.get("arguments", {}))
except ValidationError as e:
# 把错误反馈给模型,让它重试一次
return {"ok": False, "error": e.errors()}
return {"ok": True, "args": args}
校验失败不要直接报错给用户,而是把结构化错误信息作为"工具结果"回灌给模型,让它自我纠正一次。经验上,多数参数错误能在这一轮内修复,省去一次人工介入。但回灌的错误信息要简洁,把字段名和期望类型说清楚即可,别把整段异常栈丢回去,那只会增加噪声。
循环:什么时候停,比怎么转更重要
最危险的 bug 不是调用失败,而是无限循环——模型反复调用同一个工具,或 A 调 B、B 调 A。必须在循环层加硬约束:
MAX_TURNS = 8
def run_agent(messages, tools):
turns = 0
seen = set()
while turns < MAX_TURNS:
turns += 1
resp = call_model(messages, tools=tools)
if not resp.tool_calls:
return resp.content # 没有工具调用,结束
for call in resp.tool_calls:
key = (call.name, _norm(call.arguments))
if key in seen: # 环路检测
return "检测到重复调用,已终止"
seen.add(key)
result = dispatch(call) # 执行本地函数
messages.append(tool_result(call.id, result))
return "已达到最大轮次,请人工确认" # 兜底退出
三个必须有的护栏:
- 轮次上限:经验上 8–12 轮足够覆盖绝大多数任务,再多基本是卡死了。上限设太小会截断复杂任务,设太大则成本失控。
- 环路检测:维护"已调用工具 + 参数"的短期记忆,发现最近若干步内重复直接打断。注意要归一化参数再比较,避免"只是换了个空格"就绕开检测。
- 结果超时:单个工具调用要设超时,避免模型在等待中堆积上下文,也避免一个慢接口拖垮整轮。
边界条件
- 工具返回体可能很大(比如查询返回 500 条记录),必须截断后再回灌,否则下一轮上下文直接爆掉,且大返回常包含模型用不到的字段。
- 并行调用不是免费的:多个工具之间没有顺序保证,需要共享状态的工具不要并行,否则读到彼此的中间态。
- 工具报错要区分"可重试"(超时、限流)和"不可重试"(参数非法、权限不足),前者交给循环,后者立即终止并提示。
- 把"工具是否真的执行了"和"模型以为执行了"分开记录,排障时这是第一手证据,能快速定位是模型幻觉还是工具侧故障。
- 工具描述里出现的"副作用"(写入、删除、发送)要显式标明,避免模型在无关任务里顺手调用了有副作用的工具。
工具描述也要版本化
工具 schema 不是写完就不变。业务字段增减、参数取值调整,都要同步改描述,否则模型按旧 schema 调用会持续出错。把 schema 纳入代码版本管理,和接口定义一起评审,能避免"后端改了字段、AI 还在传旧值"的错位。每次改工具,都要当成改公开接口对待:有变更说明、有兼容性判断、有回归验证。
把可观测性做进循环
每次工具调用都应记录:调用了什么、传了什么参数、本地执行结果、耗时、是否触发重试与自我纠正。排障时这些日志比模型输出更有价值——它能区分"模型幻觉出不存在的工具"和"工具本身超时",也能还原循环是在第几轮失控。没有这层观测,循环一旦异常,你只能看到最终报错,无法定位中间哪一步先歪。
并行调用的隐藏成本
为提高速度,常让模型一次发起多个工具调用并行执行。但并行不等于无代价:多个调用若共享同一外部服务的配额,会互相限流;若其中某个慢,整批要等最慢的那个。更隐蔽的是依赖问题——模型有时会把本应串行的两步并行,导致后一步读不到前一步的结果。并行只适合真正独立的只读查询,凡是带状态、带副作用、彼此有顺序依赖的,都要强制串行。
别让工具成为攻击面
工具描述本质上是给模型的一段"可用能力清单",但它也可能被诱导滥用。提示注入若成功,可能诱使模型调用具有副作用的工具(发送、删除、写入)。对策是给工具分风险等级:只读工具可自由调用,写操作必须二次确认或带显式人工闸门。把工具当 API 权限来管,而不是当黑盒能力来信任。
从原型到生产的鸿沟
原型阶段工具少、调用链路短,问题暴露得慢;上线后工具变多、并发变高,schema 冲突、循环失控、超时雪崩才会集中出现。上线前最好做一次"故障注入":故意让某个工具超时、返回非法结果、重复调用,看循环能否守住。能扛住注入的系统,才敢交给真实流量。这部分投入在原型期看起来多余,到了生产期却是分水岭——很多看似可用的 agent,缺的正是这一层抗压验证。
配置即代码
工具集和循环参数应当和模型版本一起纳入配置管理,而不是散落在代码各处。这样换模型时,可以连同"该模型适配的工具描述"一起切换,避免新模型还在用旧工具的 schema。把调用相关的配置集中,能减少"模型升级后工具没跟上"这类隐性回归,也让不同模型走不同工具集这件事变得可声明、可审查。
函数调用的稳定性,七成在 schema 和容错,三成在循环护栏。把契约收紧、把异常接住、把退出写死,系统就能在不理想输出下仍保持可控。
1 view
0 likes
0 comments