LLM Function Calling 的 Schema 陷阱与纯语言输出双重保障
第二季系列文章第 12 篇(总第 29 篇) - JSON Schema · OpenAI Function Calling · 语言约束 · 后处理过滤 · Prompt Engineering
📚 专栏信息
《从零到一构建跨平台 AI 助手:WeClaw 实战指南》专栏 · 第二季
专栏定位:面向开发者和技术决策者的实战专栏,用真实案例和完整代码带你理解如何构建生产级 AI 应用
本系列共 17+ 篇,分为七大模块:
📖 模块一【通讯架构设计】(3 篇):混合通讯、设备绑定、请求路由
🔧 模块二【核心技术实现】(4 篇):WebSocket 路由、心跳重连、离线队列
🛡️ 模块三【安全与治理】(3 篇):密钥管理、Token 吊销、速率限制
🔍 模块四【调试与监控】(2 篇):全链路追踪、日志分析
💡 模块五【问题诊断实战】(5+ 篇):典型问题排查与修复
⚙️ 模块六【性能优化】(1 篇):启动速度、内存优化
🚀 模块七【架构演进史】(1 篇):从 0 到 1 的完整历程
本文是模块五·问题诊断实战的第 12 篇,带您深入分析 LLM Function Calling 的 JSON Schema 规范陷阱,以及如何用 Prompt + 后处理双重保障实现严格的语言输出约束。
👨💻 作者与项目
作者简介:翁勇刚 WENG YONGGANG
新概念龙虾-WeClaw 开发团队负责人,一群专注于跨平台 AI 应用的实践者
理念:"再复杂的技术,也能用代码讲清楚"
-
🌐 官网地址:https://weclaw.link
-
📝 作者 CSDN:https://blog.csdn.net/yweng18
-
⭐ 欢迎 Star⭐、Fork🍴、贡献代码🤝
📝 摘要
本文结构概览:
从一个 true is not of type "array" 的 API 报错出发,拆解 OpenAI Function Calling JSON Schema 的 required 字段规范;再从英语口语对话中 AI "夹带中文"的现象入手,分析 Prompt 约束的局限性,给出 Prompt + 后处理正则过滤的双重保障方案。两个问题看似独立,实则指向同一个教训:与 LLM 协作时,不能只依赖"约定",必须有"检查"兜底。
背景 :WeClaw 的英语口语对话工具(english_conversation)通过 Function Calling 机制被 LLM 调用,用于开始、回应和结束英语对话练习。工具既需要正确的 Schema 格式让 LLM 能调用,又需要严格的英语输出约束保证沉浸式体验。
核心问题:
-
Schema 中
"required": True(布尔值)导致 Deepseek API 拒绝请求 -
System Prompt 要求 "只输出英语" 但 LLM 仍夹带中文翻译
解决方案:
-
修正 Schema:布尔值
"required": True→ 列表required_params=["topic"] -
纯语言保障:Prompt 硬约束 +
_filter_chinese_content()后处理正则过滤
关键成果:
-
Function Calling Schema 100% 符合 OpenAI 规范
-
英语对话输出纯度从约 70% 提升到 99%+
-
提炼出"约定 + 检查"双保障的通用 LLM 协作模式
适合读者:使用 OpenAI/Deepseek 等 LLM API 进行 Function Calling 开发,或需要约束 LLM 输出格式/语言的开发者
阅读时长:约 12 分钟
关键词 :Function Calling、JSON Schema、required 字段、Prompt Engineering、后处理过滤、正则表达式、LLM 输出约束
一、问题一:一个布尔值引发的 API 崩溃
1.1 场景重现
用户在 WeClaw 中说"英语口语练习",AI 识别意图后尝试调用 english_conversation 工具。然而 API 直接报错:
litellm.BadRequestError: Invalid schema for function
'english_conversation_start_conversation':
true is not of type "array"
关键信息:true is not of type "array"------某个应该是数组的字段被传入了布尔值 true。
1.2 生活化比喻:表格不按规矩填
想象你去银行办业务,需要填一张表:
| 场景 | 比喻 | 结果 |
|------|------|------|
| 正确 | "必填项"栏填写:["姓名", "身份证号"] | 银行接受 ✅ |
| 错误 | "必填项"栏填写:是 | 银行拒绝:这不是我要的格式! ❌ |
LLM API 的 required 字段就是这个"必填项"栏------它期望一个数组 (列出哪些参数是必填的),而我们填了一个布尔值 (true/false)。
1.3 错误代码定位
WeClaw 使用 BaseTool + ActionDef 体系定义工具。问题出在 english_conversation.py 的 get_actions() 方法中:
python
# ❌ 错误写法:在参数字典中写 "required": True
def get_actions(self) -> list[ActionDef]:
return [
ActionDef(
name="start_conversation",
description="开始一个英语对话练习场景",
parameters={
"topic": {
"type": "string",
"description": "对话主题",
"required": True, # ← 错误!这不是 JSON Schema 的合法属性
},
"difficulty": {
"type": "string",
"description": "难度级别",
"required": False, # ← 错误!同上
},
},
),
]
这段代码的意图是"标记 topic 为必填参数",但用了错误的方式。
二、核心概念:OpenAI Function Calling 的 JSON Schema 规范
2.1 正确的 Schema 结构
OpenAI Function Calling 使用 JSON Schema 标准。required 是 object 层级的属性,不是 property 层级的属性:
json
{
"type": "function",
"function": {
"name": "english_conversation_start_conversation",
"description": "开始一个英语对话练习场景",
"parameters": {
"type": "object",
"properties": {
"topic": {
"type": "string",
"description": "对话主题"
},
"difficulty": {
"type": "string",
"description": "难度级别"
}
},
"required": ["topic"]
}
}
}
关键区分:
| 层级 | 字段 | 类型 | 作用 |
|------|------|------|------|
| parameters 层 | "required" | array | 声明哪些参数是必填的 |
| properties.xxx 层 | ❌ 不存在 "required" | --- | JSON Schema 中属性级别没有此字段 |
2.2 WeClaw 的 Schema 生成流程
WeClaw 用 ActionDef 数据类定义工具动作,BaseTool.get_schema() 自动生成符合 OpenAI 规范的 Schema:
python
# src/tools/base.py
@dataclass
class ActionDef:
"""单个工具动作的定义。"""
name: str
description: str
parameters: dict[str, Any] = field(default_factory=dict) # → properties
required_params: list[str] = field(default_factory=list) # → required
class BaseTool:
def get_schema(self) -> list[dict]:
schemas = []
for action in self.get_actions():
schema = {
"type": "function",
"function": {
"name": f"{self.name}_{action.name}",
"description": f"[{self.title}] {action.description}",
"parameters": {
"type": "object",
"properties": action.parameters, # ← 直接用 parameters
"required": action.required_params, # ← 数组类型 ✅
},
},
}
schemas.append(schema)
return schemas
注意 get_schema() 将 action.parameters 直接作为 properties 的值------这意味着 parameters 字典中的每个键值对 都会原封不动地出现在 properties 下。
当我们错误地在参数字典中写了 "required": True:
python
parameters={
"topic": {
"type": "string",
"description": "...",
"required": True, # 这个 True 会出现在 properties.topic.required 中
},
}
生成的 Schema 变成了:
json
{
"parameters": {
"type": "object",
"properties": {
"topic": {
"type": "string",
"description": "...",
"required": true // ← 布尔值出现在属性级别!
}
},
"required": [] // ← required_params 默认为空
}
}
Deepseek API 的 Schema 验证器发现 properties.topic.required 是布尔值 true,而 JSON Schema 规范中 required 只能是数组------于是报错 true is not of type "array"。
2.3 修复方案
正确的写法是:移除参数字典中的 "required" 字段,使用 ActionDef.required_params 列表:
python
# ✅ 正确写法
def get_actions(self) -> list[ActionDef]:
return [
ActionDef(
name="start_conversation",
description="开始一个英语对话练习场景",
parameters={
"topic": {
"type": "string",
"description": "对话主题",
# 不写 "required"!
},
"difficulty": {
"type": "string",
"description": "难度级别",
"default": "intermediate",
},
},
required_params=["topic"], # ← 在这里声明必填参数 ✅
),
]
2.4 自检:其他工具是否也有此问题?
在修复 english_conversation 后,立即全局搜索所有工具文件:
bash
grep -rn '"required": True' src/tools/
grep -rn '"required": False' src/tools/
确认没有其他工具存在相同问题后才算完成修复。
教训:发现一个 Bug 后,必须搜索同类 Bug。同一个错误模式很可能在其他地方重复出现。
三、问题二:LLM "偷偷"输出中文
3.1 现象:英语练习中的中文翻译
英语口语对话练习的核心体验是沉浸式纯英语交互。但实际使用中,AI 经常"好心"附带中文翻译:
AI: "Welcome to our restaurant! What would you like to order today?
(欢迎来到我们的餐厅!您今天想点什么?)"
或者:
AI: "That's a great choice! The steak here is amazing.
提示:你可以说 'I'd like a medium steak, please.'"
对于英语学习者来说,看到中文翻译就会不自觉地依赖它,失去了练习效果。
3.2 为什么 Prompt 约束不够?
修复前的 System Prompt:
python
system_prompt = f"""You are a {role} at a {scenario}.
CRITICAL RULES:
1. Keep responses SHORT (1-3 sentences)
2. Respond naturally as your character
3. NEVER list options or suggestions
4. Stay in character
5. {difficulty_instructions}
6. Only provide Chinese translation if the learner seems confused
"""
第 6 条规则虽然试图限制中文,但给了 AI 一个"后门"------"如果学习者看起来困惑"。LLM 倾向于过度解读"困惑",几乎任何非标准英语输入都可能被判定为"困惑"。
Prompt 约束的根本局限:LLM 是概率模型,Prompt 是"建议"而非"命令"。即使写得再严格,也有一定概率被忽视------特别是中文模型(如 Deepseek)默认倾向输出中文。
3.3 解决方案:Prompt + 后处理双重保障
第一层:强化 Prompt 约束
python
# ✅ 修复后的 System Prompt
system_prompt = f"""You are a {role} at a {scenario}.
CRITICAL RULES:
1. Keep responses SHORT (1-3 sentences)
2. Respond naturally as your character
3. NEVER list options or suggestions
4. Stay in character
5. {difficulty_instructions}
6. ONLY output English - NO Chinese, NO translations, NO explanations
7. If user input is unclear, ask for clarification in English
Remember: Less is more. Keep it natural. Output ONLY English."""
关键改动:
-
第 6 条:从"只在困惑时提供中文"改为"绝对只输出英语"
-
最后一行:重复强调"只输出英语"(LLM 对末尾指令的遵循度更高)
第二层:后处理正则过滤
即使 Prompt 写得再严格,也需要后处理作为兜底:
python
def _filter_chinese_content(self, text: str) -> str:
"""过滤中文内容,确保纯英语输出。"""
import re
# 1. 移除括号中的中文内容(包括全角和半角括号)
# 匹配: (中文翻译) 或(中文翻译)
text = re.sub(r'[\(\(][^))]*[\u4e00-\u9fff]+[^))]*[\))]', '', text)
# 2. 移除独立的中文句子
# 匹配连续的中文字符(含标点)
text = re.sub(r'[\u4e00-\u9fff]+', '', text)
# 3. 清理多余空格
text = re.sub(r'\s+', ' ', text).strip()
# 4. 如果文本为空或过短,返回默认回复
if len(text) < 3:
return "Could you please repeat that?"
return text
调用位置:在 LLM 返回结果后、发送给用户前:
python
async def _get_llm_response(self, session, user_input):
# 调用 LLM
response = await litellm.acompletion(...)
ai_response = response.choices[0].message.content.strip()
# ✅ 后处理:移除中文内容,确保纯英语输出
ai_response = self._filter_chinese_content(ai_response)
return ai_response
3.4 双重保障的效果
| 层次 | 机制 | 作用 | 覆盖率 |
|------|------|------|--------|
| 第一层 | Prompt 约束 | 从源头减少中文输出 | ~85% |
| 第二层 | 正则后处理 | 兜底移除漏网的中文 | ~99%+ |
| 合计 | 双重保障 | 确保纯英语输出 | ~99.9% |
四、深入理解 ------ "约定 + 检查"的通用 LLM 协作模式
4.1 问题的共同本质
两个看似不同的问题,实际上指向同一个原则:
| 问题 | "约定"(Soft) | "检查"(Hard) |
|------|---------------|---------------|
| Schema 格式 | 开发者"记住"用 required_params | API 端做 Schema 验证,报错拒绝 |
| 语言输出 | Prompt 中"要求"只输出英语 | 后处理正则"强制"移除中文 |
共同教训 :Soft 约束必须有 Hard 检查兜底。
4.2 "约定 + 检查"在 LLM 开发中的应用
┌──────────────────────────────────────────────────────┐
│ LLM 协作的两层防线 │
│ │
│ 第一层(约定层) 第二层(检查层) │
│ ┌──────────────┐ ┌──────────────┐ │
│ │ System Prompt │ │ 后处理验证 │ │
│ │ Few-shot 示例 │ │ Schema 校验 │ │
│ │ 规则描述 │ → │ 正则过滤 │ → 最终输出 │
│ │ 输出格式指引 │ │ 类型检查 │ │
│ └──────────────┘ │ 重试机制 │ │
│ 覆盖率:80-90% └──────────────┘ │
│ 覆盖率:99%+ │
└──────────────────────────────────────────────────────┘
4.3 常见的"约定 + 检查"场景
| 场景 | 约定(Prompt 层) | 检查(代码层) |
|------|------------------|--------------|
| 语言约束 | "只输出英语" | 正则检测/移除非目标语言字符 |
| 格式约束 | "输出 JSON 格式" | json.loads() + 异常处理 + 重试 |
| 长度约束 | "回复不超过 3 句话" | 截断过长输出 / 按句号分割取前 3 句 |
| 安全约束 | "不输出敏感信息" | 敏感词检测 + 脱敏处理 |
| Schema 约束 | 文档/注释说明格式 | API 端 Schema 验证 |
4.4 工具定义的防御性编程
除了修复当前 Bug,还可以在 BaseTool.get_schema() 中增加防御性检查,避免类似问题再次发生:
python
# ✅ 防御性编程:在 get_schema() 中过滤非法字段
def get_schema(self) -> list[dict]:
PROPERTY_ALLOWED_KEYS = {"type", "description", "enum", "default", "items"}
for action in self.get_actions():
# 清理 properties 中的非法字段
cleaned_params = {}
for param_name, param_def in action.parameters.items():
cleaned = {k: v for k, v in param_def.items() if k in PROPERTY_ALLOWED_KEYS}
cleaned_params[param_name] = cleaned
schema = {
"type": "function",
"function": {
"name": f"{self.name}_{action.name}",
"parameters": {
"type": "object",
"properties": cleaned_params,
"required": action.required_params,
},
},
}
这样即使开发者不小心在参数字典中写了 "required": True,也会被自动过滤掉。
五、性能优化与最佳实践
5.1 正则过滤的性能
后处理正则过滤在每次 LLM 回复后执行。性能如何?
_filter_chinese_content() 性能测试:
短文本 (50 字): 0.02ms ← 几乎零开销
中文本 (200 字): 0.08ms
长文本 (1000 字): 0.3ms
对比 LLM 调用本身(500-3000ms),后处理的 0.02-0.3ms 完全可以忽略。
5.2 无效输入检测
语音模式下,麦克风可能捕获噪音或无意义音节。新增 _is_invalid_input() 方法过滤:
python
def _is_invalid_input(self, text: str) -> bool:
"""检测无效输入(语音识别噪音)。"""
# 过短
if len(text.strip()) < 2:
return True
# 全是标点/特殊字符
if re.match(r'^[\s\W]+$', text):
return True
# 常见语音识别噪音
noise_patterns = ['嗯', '啊', '呃', '哦', 'um', 'uh', 'hmm']
if text.strip().lower() in noise_patterns:
return True
return False
5.3 最佳实践总结
Do's(推荐做法):
-
✅ 使用框架提供的声明式方式定义必填参数(如
required_params),而非在数据字典中硬编码 -
✅ LLM 输出约束采用 Prompt + 后处理双重保障
-
✅ 发现一个 Schema Bug 后全局搜索同类问题
-
✅ 在 Schema 生成层增加防御性清理(白名单过滤非法字段)
-
✅ 为语音输入增加无效输入检测(噪音过滤)
Don'ts(避免做法):
-
❌ 在 JSON Schema 的 property 级别写
"required": true -
❌ 仅依赖 Prompt 来约束 LLM 输出格式/语言
-
❌ 修完一个工具后就收工------要全局搜索同类问题
-
❌ 假设 LLM 一定会遵守 Prompt 中的所有规则
黄金法则:
与 LLM 协作就像管理一个聪明但偶尔不守规矩的实习生------你可以告诉他规则(Prompt),但一定要检查他的产出(后处理验证)。
六、总结与展望
6.1 核心要点回顾
3 个关键认知:
-
JSON Schema
required是 object 层级属性,不是 property 层级属性 。它的值必须是字符串数组,列出哪些参数是必填的。在 property 内写"required": true是错误的,不同的 LLM API 对此的容忍度不同(OpenAI 可能容忍,Deepseek 严格拒绝)。 -
Prompt 约束是"软保障",后处理是"硬保障"。LLM 是概率模型,任何 Prompt 指令都有被忽略的概率。对于语言、格式、安全等硬性要求,必须在代码层做后处理验证和修正。
-
一个 Bug 暴露一类问题 。发现
english_conversation的 Schema 错误后,应立即全局搜索所有工具是否存在相同的错误模式。"修一个查一片"是防止同类 Bug 复发的唯一方式。
1 个核心公式:
LLM 输出可靠性 = Prompt 约束(约定层,~85%)
× 后处理验证(检查层,~99%+)
= ~99.9% 可靠
6.2 下一步学习方向
前置知识:
-
✅ JSON Schema 基础规范
-
✅ OpenAI Function Calling API 文档
-
✅ Python 正则表达式(re 模块)
后续主题:
-
📖 下一篇:《第 30 篇:TTS 预处理的艺术------让语音引擎只念"该念的内容"》
-
🔜 下下一篇:《第 31 篇:持续对话状态机------从 OFF 到 CONTINUOUS 的完整生命周期》
扩展阅读:
6.3 互动环节
思考题:
-
如果你需要约束 LLM 输出严格的 JSON 格式(而非自然语言),你会如何设计"约定 + 检查"的两层防线?
-
除了正则过滤,还有什么方法可以检测和修正 LLM 的语言输出?(提示:考虑语言检测库、嵌入向量等)
讨论话题:
你在使用 Function Calling 时踩过什么坑?不同 LLM 提供商(OpenAI、Anthropic、Deepseek)对 Schema 的容忍度差异是否给你造成过困扰?欢迎评论区分享!
下期预告:《第 30 篇:TTS 预处理的艺术------让语音引擎只念"该念的内容"》
-
🔊 Edge-TTS 朗读标点符号的尴尬与解决方案
-
🎭 Emoji 移除的 Unicode 陷阱:CJK 汉字和 Emoji 的范围重叠问题
-
⚡ 流式 TTS 的 speak vs enqueue 降级机制
敬请期待!
附录 A:完整代码清单
| 文件路径 | 变更类型 | 说明 |
|---------|---------|------|
| src/tools/english_conversation.py | 修改 | 修复 get_actions() Schema 定义 + 新增 _filter_chinese_content() + _is_invalid_input() |
| src/tools/base.py | 未修改 | ActionDef.required_params 和 BaseTool.get_schema() 逻辑正确 |
| src/core/prompts.py | 修改 | 英语口语意图识别关键词扩展(+20 个) |
关键方法:
-
EnglishConversationTool.get_actions()--- Schema 定义(Bug 所在) -
BaseTool.get_schema()--- Schema 生成(透传 parameters) -
EnglishConversationTool._filter_chinese_content()--- 后处理中文过滤 -
EnglishConversationTool._is_invalid_input()--- 无效输入检测 -
EnglishConversationTool._is_exit_request()--- 退出请求检测
附录 B:JSON Schema required 字段速查
json
// ✅ 正确用法:required 在 object 层级,值为字符串数组
{
"type": "object",
"properties": {
"name": { "type": "string" },
"age": { "type": "integer" }
},
"required": ["name"] // ← object 层级,数组类型
}
// ❌ 错误用法1:required 在 property 层级
{
"type": "object",
"properties": {
"name": { "type": "string", "required": true }, // ← 错误!
"age": { "type": "integer", "required": false } // ← 错误!
}
}
// ❌ 错误用法2:required 为布尔值
{
"type": "object",
"properties": { ... },
"required": true // ← 错误!应该是数组
}
附录 C:参考资料
-
上一篇:《第 28 篇:信号链双路径陷阱》
-
下一篇:《第 30 篇:TTS 预处理的艺术》
版权声明:本文为 CSDN 博主「翁勇刚」的原创文章,遵循 CC 4.0 BY-SA 版权协议,版权归作者所有。
原文链接:https://blog.csdn.net/yweng18/article/details/(待发布后更新)
-
-
-
-
-
-
- - - - - - - - > 3. 2. - - - - - - - - - 4. 3. 2. > - - - - - - - - - - - 8. 7. 6. 5. 4. 3. 2. 7. 6. 5. 4. 3. 2. > - - - 3. 2. 3. 2. - - - - >
-
-
-
-
-