修复 LLM 输出的代码中经常出现多余引号的问题
我们在做 Agent 项目的时候,碰到了一个让人摸不着头脑的 bug:模型生成的代码有时候能跑,有时候报 JSON 解析错误,复现概率还挺高。
问题出在哪
我们的 Agent 需要让模型动态生成 Python 代码。代码通过 function calling 传递——模型调用工具时,把生成的代码放在参数里,后端解析后执行。
某天执行日志里开始频繁出现这个错误:
params is not valid JSON
Received: {
\\\"hwnd\\\": 133386,
\\\"keys\\\": \\\"火箭{ENTER}\\\",
\\\"explanation\\\": \\\"...\\\"
}同一个工具,同样的参数格式,有时成功,有时失败。翻了半天日志,发现规律:成功的调用里 params 是 JSON 对象,失败的调用里 params 是模型自己序列化出来的 JSON 字符串。
正常的调用:
{
"method": "send_keys",
"params": {
"hwnd": 133386,
"keys": "火箭{ENTER}",
"explanation": "在搜索框中输入联系人名称"
}
}出问题的调用:
{
"method": "send_keys",
"params": "{
\\n \\\"hwnd\\\": 133386,
\\n \\\"keys\\\": \\\"火箭{ENTER}\\\",
\\n \\\"explanation\\\": \\\"在搜索框中输入联系人名称\\\"
\\n}"
}
params 本该是对象,模型给整成了一个带双重转义的字符串。后端 JSON.parse 拿到的是字面量 \\n 和 \\" ,不是换行符和引号,解析当然失败。
为什么会这样
Function calling 通过 JSON 传参,JSON 规范要求字符串里的双引号转义为 \"、换行符转义为 \n。这层转义正常情况下由 SDK 处理,开发者直接把值传进去就好。
但模型有时候会在内容层再多加一层:生成代码时,把 " 写成 \",把换行写成 \n。SDK 再序列化一次,就变成了双重转义:
" → \" → \\\"
\n → \n → \\n
本质上是模型把传输层的转义规则错误地应用到了代码内容上——它"记住"了 JSON 字符串里引号要加反斜杠,但没意识到 SDK 已经帮它做了这一步。
不止我们遇到过
查了一圈,发现 Aider 和 OpenCode 都踩过同样的坑。
Aider 早在 2023 年就做过专项 benchmark,在 133 道 Exercism Python 练习题上对比了 function calling 和纯文本两种传代码的方式,结论是:
"Function calls performed worse than the whole file method, for all the models."
GPT-3.5 在 function calling 模式下甚至会直接忽略 JSON schema,产生幻觉式的函数调用。研究者的最终决定是不采用 functions API,Aider 的 function calling 版本代码编辑器此后全部废弃。
2024 年他们又做了一篇 LLMs are bad at returning code in JSON,在 Claude 3.5 Sonnet、DeepSeek Coder V2、GPT-4o 等模型上验证了同样的结论:
"LLMs produce lower quality code if they're asked to return it as part of a structured JSON response."
让模型在 JSON 里返回代码,它要分心处理转义逻辑,代码质量会整体下降,语法错误率也会上升。
OpenCode 的 issue #34652 记录的是另一个变体:Claude 通过原生 Anthropic provider 调用工具时,会把本应是数组的参数以 JSON 字符串的形式传过来:
SchemaError(Expected array, got "[{\"content\":...,\"status\":...,\"priority\":...}]")
got 后面是一个带转义引号的字符串,不是真正的数组。模型多做了一次 JSON 序列化。这个 issue 被标记为间歇性复现、无法从用户侧规避,至今还在等官方修复。
Aider 和 OpenCode 的思路
两个项目的处理方向不同。
Aider 选择从根本上绕开:彻底放弃 function calling,改用纯文本传代码。模型输出 Markdown 代码块,接收端用正则提取。模型只需要"写代码",不用管序列化格式,转义问题自然消失。
OpenCode 的方向是在 schema 校验层做兼容:收到字符串时先 JSON.parse 一次,解析成功就继续走。不改变模型的输出行为,只是在后端多做一层容错。
我们的做法
我们参考了 Aider 的思路,但不想完全放弃 function calling,所以加了两层处理。
第一层是预防。 在 commit_code_patch 的 patch 字段 description 里,明确要求模型必须用 codeblock 包裹代码:
```python
# 你的代码
```
这样做有两个效果:模型进入"写代码"的心智模型而不是"生成 JSON 字符串";Markdown codeblock 内部不需要任何转义,写什么就是什么。
另外在 prepare_code_build(生成代码前必须调用的工具)的返回值末尾加了一条格式提醒,每次生成前强化一次。
接收端新增 codeblock 提取逻辑,没有 codeblock 时向后兼容,走原有逻辑。
第二层是兜底。 即使 prompt 引导到位,模型偶尔也可能不按格式来。我们在 AST 校验环节做了增强:校验失败时,错误信息附带报错行号和对应的代码上下文,而不是只返回一个笼统的 SyntaxError。
SyntaxError at line 14:
result = f"value: {data[\"key\"]}"
^
invalid syntax (possibly due to extra backslash escaping)
模型看到精确的行号和出错代码,下一轮自行修复的成功率高很多。
改完之后的效果
用一个容易触发转义问题的输入做了 A/B 测试,模型是 Gemini Flash,各跑 20 次并发执行:
| 指标 | 修复前 | 修复后 |
|---|---|---|
| AST 通过率 | 9/20(45%) | 20/20(100%) |
| codeblock 使用率 | 0/20(0%) | 19/20(95%) |
| 多余反斜杠转义 | 11/20(55%) | 0/20(0%) |
codeblock 使用率是 95% 而不是 100%,说明 prompt 引导本身不是万能的——所以才需要 AST 兜底,两层加在一起,才能把问题压到可接受的范围。