Function Calling最隐蔽的坑
我让AI调了1000次API,才发现Function Calling的“结构化输出”就是个谎言
上周三凌晨2:47,我被PagerDuty叫醒。
第43次“JSON解析失败”的告警。我眯着眼看监控,突然想明白一件事——我们是不是把AI的结构化输出想得太美好了?
先丢个数据:我扒了公司过去三个月的GPT-4-0613调用日志,纯JSON格式错误率12.7%。注意,不是语义错,不是参数填错,是格式本身崩了——多个逗号、少个引号、突然给你塞一行// TODO: 待确认。
你可能会说:“Jordan你是不是没开response_format?”
呵呵。
我不但开了,我还写了三层fallback。系统提示词里我加了三遍“必须返回合法JSON”,加粗的那种。但问题就在这儿——模型信心不足的时候,它会“创造性地”破坏你的结构约束。你越想把它框住,它越要搞事。
那个让我半夜惊醒的Bug
需求很简单。从用户对话里提取订单信息,输出这个Schema:
{
"order_id": "string",
"items": [{"name": "string", "quantity": "integer"}],
"total": "number"
}人畜无害对吧?我按OpenAI的文档老老实实配了functions参数,写了贼详细的description。
前100次测试,完美。
上线第一周,完美。
直到那个用户输入:
“我要退货,就是上次那个黑色的,不对,好像是深蓝色的那件,反正就是399那个”
模型返回:
{
"order_id": null,
"items": [{"name": "那件黑色的(也可能是深蓝色)", "quantity": 1}],
"total": "399" // 注意:这里total变成了字符串
}看到没?total字段类型直接崩了。因为用户描述颜色时表现出的不确定性,模型在输出数值时也开始“犹豫”,自作主张给数字套上了引号。
这就是Function Calling第一个坑:语义不确定会传染给语法结构。
等等,这里我要更正一下——准确说不是“传染”,是模型在处理模糊输入时,会在结构层面产生某种“代偿行为”。我在2024年NIPS的一个workshop上看到过相关论文,他们管这个叫“structural hedging”。大概意思是模型通过破坏结构来表达“我也不确定”。嗯...这个比较复杂,先不展开。
Schema约束为什么会失效?
很多人不理解。明明定义了type: "number",模型为什么还输出字符串?
这里有个反直觉的事实:Function Calling的参数定义对模型来说只是“强烈建议”,不是硬约束。
从token生成的角度看,functions参数就是上下文的一部分。模型在生成"total":之后的下一个token时,面临两股力量:
- 语法压力:应该输出数字(来自Schema)
- 语义压力:用户不确定,得保留模糊性(来自对话)
语义压力大的时候,模型会选“折中方案”——用字符串包数字。
我测过另一个更离谱的case:
# 定义函数参数
"parameters": {
"type": "object",
"properties": {
"user_age": {"type": "integer"}
}
}
# 用户输入:“我大概三十出头吧”
# 模型输出:{"user_age": "30-35"}
# ← 直接无视类型约束,还给你来了个rangeOpenAI的技术支持跟我说这是“预期行为”。我:???
后来我想了想,据我了解,这跟模型训练时的alignment策略有关——RLHF阶段如果过于强调“理解用户意图”,模型就会觉得理解比格式重要。
三个保命机制
那次事故之后,我搞了一套“防御性解析”策略。错误率从12.7%压到了0.3%以下。
1. JSON修复器 + 类型强制转换
别再指望模型输出完美JSON了。把它当成喝醉的朋友给你写的便条。
我用的json_repair库,但光修复不够,还得加类型强制:
import json_repair
from pydantic import BaseModel, ValidationError
import re
def safe_parse(llm_output: str, schema: BaseModel):
# 第一层:修复畸形JSON
repaired = json_repair.repair_json(llm_output)
# 第二层:类型强制转换
data = json.loads(repaired)
for field_name, field_info in schema.model_fields.items():
if field_info.annotation == int and isinstance(data.get(field_name), str):
# 尝试提取字符串中的数字
numbers = re.findall(r'\d+', data[field_name])
if numbers:
data[field_name] = int(numbers[0])
# 第三层:Schema验证
return schema(**data)魔鬼细节:永远不要用json.loads直接解析。先修再解。 我见过模型输出{'total': 399,}这种Python字典格式,也见过// 这是总价\n"total": 399这种带注释的JSON。json_repair能扛住大部分情况。
2. 约束采样
这才是真正的核武器。
与其让模型自由生成再修,不如在生成阶段就卡死。
我用outlines库,它直接改模型的logits来强制符合Schema:
import outlines
schema = """
{
"type": "object",
"properties": {
"total": {"type": "number"}
}
}
"""
generator = outlines.generate.json(model, schema)
result = generator("用户说总共花了399元")
# result["total"]保证是数字类型原理简单粗暴:模型要生成total字段值时,outlines把所有非数字token的概率直接压到负无穷。模型根本没机会输出引号。
代价?推理速度慢了15-20%。每一步都要做Schema校验,相当于给模型戴手铐。但值。
3. 结构化输出的“降级策略”
这个是我最近在实验的。
想法很简单:模型对某个字段信心不足时,允许它输出“带元数据的结构化数据”。
{
"total": 399,
"_metadata": {
"total_confidence": 0.92,
"total_raw": "399元",
"needs_human_review": false
}
}好处三个:
- 不破坏主Schema结构
- 保留模型的“不确定性”信息
- 下游系统根据confidence决定是否人工介入
我在一个客服系统试了这套方案,人工介入率降了40%。因为大部分“模糊”场景其实不需要人工,只是之前的系统分不清“真模糊”和“假模糊”。
说点得罪人的
Function Calling这个功能,从产品角度看,是个半成品。
去年11月OpenAI DevDay上他们又画了个Structured Outputs的大饼,说格式遵循率100%。我测了,GPT-4o-2024-08-06那个版本确实好了不少,但在长上下文、多轮对话场景里还是有翻车的时候。
我见过太多团队(包括半年前的我)把Function Calling当数据库查询用。结果就是凌晨三点爬起来修JSON解析错误。
更讽刺的是,Claude在结构化输出上反而更稳。我跑过同样的测试集,Claude 3.5 Sonnet的格式错误率只有3.2%。我猜是Anthropic在RLHF阶段对格式正确性给了更高权重。
当然这不是让你去选边站队。问题在于:整个行业对LLM的结构化输出能力有系统性高估。
你去HN或者r/MachineLearning上逛一圈,每隔几天就有人发帖说“为什么我的Function Calling总是返回坏JSON”。然后下面一堆人回复“你prompt写得不够好”。放屁。
你应该怎么做?
如果你现在就要在生产环境用Function Calling,三条:
1. 永远别信任模型的输出格式,哪怕它之前100次都对了
2. 约束采样比提示词工程有效10倍,但要牺牲点灵活性
3. 设计Schema时留逃生舱,比如_metadata字段,给不确定性一个出口
最后问你们一个问题:你们在生产环境遇到过最离谱的Function Calling输出是啥?
我见过模型在JSON里写了段道歉信——“抱歉我不确定这个参数应该是什么,以下是几种可能:”。还见过它在array字段里返回了Markdown表格。
评论区说说你的故事。我挑最离谱的三个寄出HackerNoon的贴纸。我去年在SF的AI Engineer Summit上薅了一大把,终于有机会清库存了。
相关文章:
- 《为什么你的Prompt Engineering其实是Overfitting》
- 《LLM时代的防御性编程:从Try-Catch到Schema-Fix》
- 《我让GPT-4和Claude 3.5互审代码,结果发现了47个Bug》
#programming #AI #function-calling #LLM #生产环境踩坑
读者评论 2