返回博客列表
技术教程2026-04-1530 分钟阅读

LLM Function Calling的Schema陷阱与纯语言输出双重保障

JSON Schema · OpenAI Function Calling · 语言约束 · 后处理过滤 · Prompt Engineering

WeClaw_29_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 应用的实践者
理念:"再复杂的技术,也能用代码讲清楚"


📝 摘要

本文结构概览: 从一个 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 能调用,又需要严格的英语输出约束保证沉浸式体验。

核心问题

  1. Schema 中 "required": True(布尔值)导致 Deepseek API 拒绝请求
  2. System Prompt 要求 "只输出英语" 但 LLM 仍夹带中文翻译

解决方案

  1. 修正 Schema:布尔值 "required": True → 列表 required_params=["topic"]
  2. 纯语言保障:Prompt 硬约束 + _filter_chinese_content() 后处理正则过滤

关键成果

  • Function Calling Schema 100% 符合 OpenAI 规范
  • 英语对话输出纯度从约 70% 提升到 99%+
  • 提炼出"约定 + 检查"双保障的通用 LLM 协作模式

适合读者:使用 OpenAI/Deepseek 等 LLM API 进行 Function Calling 开发,或需要约束 LLM 输出格式/语言的开发者

阅读时长:约 12 分钟

关键词Function CallingJSON Schemarequired 字段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.pyget_actions() 方法中:

# ❌ 错误写法:在参数字典中写 "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 标准。requiredobject 层级的属性,不是 property 层级的属性:

{
  "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:

# 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

parameters={
    "topic": {
        "type": "string",
        "description": "...",
        "required": True,      # 这个 True 会出现在 properties.topic.required 中
    },
}

生成的 Schema 变成了:

{
  "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 列表:

# ✅ 正确写法
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 后,立即全局搜索所有工具文件:

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:

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 约束

# ✅ 修复后的 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 写得再严格,也需要后处理作为兜底:

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 返回结果后、发送给用户前:

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_paramsAPI 端做 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() 中增加防御性检查,避免类似问题再次发生:

# ✅ 防御性编程:在 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() 方法过滤:

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 个关键认知

  1. JSON Schema required 是 object 层级属性,不是 property 层级属性。它的值必须是字符串数组,列出哪些参数是必填的。在 property 内写 "required": true 是错误的,不同的 LLM API 对此的容忍度不同(OpenAI 可能容忍,Deepseek 严格拒绝)。

  2. Prompt 约束是"软保障",后处理是"硬保障"。LLM 是概率模型,任何 Prompt 指令都有被忽略的概率。对于语言、格式、安全等硬性要求,必须在代码层做后处理验证和修正。

  3. 一个 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 互动环节

思考题

  1. 如果你需要约束 LLM 输出严格的 JSON 格式(而非自然语言),你会如何设计"约定 + 检查"的两层防线?
  2. 除了正则过滤,还有什么方法可以检测和修正 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_paramsBaseTool.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 字段速查

// ✅ 正确用法: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:参考资料

  1. OpenAI Function Calling 文档
  2. JSON Schema Specification (Draft 2020-12)
  3. Deepseek API 工具调用文档
  4. LiteLLM 多模型兼容文档
  5. Python re 模块文档
  6. 上一篇:《第 28 篇:信号链双路径陷阱》
  7. 下一篇:《第 30 篇:TTS 预处理的艺术》

版权声明:本文为 CSDN 博主「翁勇刚」的原创文章,遵循 CC 4.0 BY-SA 版权协议,版权归作者所有。

原文链接https://blog.csdn.net/yweng18/article/details/(待发布后更新)