OpenRouter 的 response healing 插件在修什么:不是所有畸形输出都能救
结构化输出这条链路上最烦人的一类故障,是它不稳定:同一段 prompt、同一个 schema,大多数时候 JSON.parse 好好的,偶尔抛一次异常。翻日志一看,模型把 JSON 包在 markdown 围栏里了,或者结尾少了个花括号。
OpenRouter 有个叫 response-healing 的 plugin 就是冲着这个来的。挂上之后如果还有一部分请求在炸——这时候真正难的不是修,是分清楚这一撮属于「插件没生效」还是「文档写明它本来就不修」。这两种情况的处置方向完全相反,前者要改请求,后者改请求也没用。注意官方文档对这个插件的措辞是「attempts to repair」(尝试修复),并没有给出任何成功率口径,我们也没有实测数据,所以这篇不谈它能修好多少比例,只谈边界画在哪。
下面按官方文档《Response Healing》页(openrouter.ai/docs/guides/features/plugins/response-healing)的写法把边界抄清楚,再给一套可以自己走一遍的判定顺序。
先把这个插件声称的能力抄下来
文档在 Overview 一节只列了两项能力,一项都没多:
- Automatic JSON repair:修复缺失的括号、逗号、引号以及其它语法错误
- Markdown extraction:从 markdown 代码块里把 JSON 提取出来
这两句话很短,但它是后面所有判断的地基。文档没有说这个插件会按你的 schema 补字段、纠正类型、或者对语义做任何校验——它说的是格式层面的修复与提取。这一点先记住,第五步会用到。
紧接着的「What Gets Fixed」一节给了五组输入输出示例,每组一个小标题,逐条都带原始畸形文本和修复后的结果:
| 文档列出的类型 | 文档给的畸形输入示例 | 文档给的输出 |
|---|---|---|
| JSON Syntax Errors | {"name": "Alice", "age": 30 | {"name": "Alice", "age": 30} |
| Markdown Code Blocks | ```json 围栏包住的 {"name": "Bob"} | {"name": "Bob"} |
| Mixed Text and JSON | Here's the data you requested: 后面跟 {"name": "Charlie", "age": 25} | {"name": "Charlie", "age": 25} |
| Trailing Commas | {"name": "David", "age": 35,} | {"name": "David", "age": 35} |
| Unquoted Keys | {name: "Eve", age: 40} | {"name": "Eve", "age": 40} |
五个类型,是我在这一页上逐个小标题数出来的,不是概括。它们的共同点很明显:都是「模型多说了话」或者「模型写得不严谨」,但内容本体是完整的。缺右括号那条也一样——键值对都在,只差一个收尾符号。
第二步:怎么确认是「插件根本没介入」
这是最常见的一种,而且判定动作很简单,不需要任何工具。
文档在「How It Works」一节写明了激活条件,原话的三个要件缺一不可:非流式请求、使用了 response_format 且 type 是 json_schema 或 json_object、在 plugins 数组里带上了这个插件。
对着这三条去核你自己的请求体:
- 请求里有没有
stream: true。有的话就不用往下查了,文档在 Limitations 一节用一个 Warning 单独写了「Non-Streaming Requests Only」——只对非流式请求生效。很多人的服务是为了首字延迟统一开的流式,自己都忘了。 response_format在不在,type是不是那两个取值之一。只在 system prompt 里写「请只输出 JSON」而没有带response_format,按文档写明的条件是不会激活的。plugins数组里的 id 有没有写对。文档示例里是{ id: 'response-healing' },中间是连字符。
配套的判定动作是:在 JSON.parse / json.loads 之前,先把 choices[0].message.content 这个原始字符串原样打出来或落盘。绝大多数人的代码是拿到就 parse,异常一抛,真正的畸形内容压根没进日志,后面查什么都是猜。先把原文留下来,你才能对着上面那张表判断它属于哪一类。
第三步:文档语义给出的处置
处置就是把三个激活条件补齐。文档的 Complete Example 一节给了 TypeScript 和 Python 两份完整示例,Python 那份原样是这样(示例里的密钥占位我按脱敏要求写成占位符):
import requests
import json
response = requests.post(
"https://openrouter.ai/api/v1/chat/completions",
headers={
"Authorization": f"Bearer <OPENROUTER_API_KEY>",
"Content-Type": "application/json",
},
json={
"model": "google/gemini-2.5-flash",
"messages": [
{
"role": "user",
"content": "Generate a product listing with name, price, and description"
}
],
"response_format": {
"type": "json_schema",
"json_schema": {
"name": "Product",
"schema": {
"type": "object",
"properties": {
"name": {"type": "string", "description": "Product name"},
"price": {"type": "number", "description": "Price in USD"},
"description": {"type": "string", "description": "Product description"}
},
"required": ["name", "price"]
}
}
},
"plugins": [
{"id": "response-healing"}
]
}
)
data = response.json()
product = json.loads(data["choices"][0]["message"]["content"])
print(product["name"], product["price"])
示例里的 google/gemini-2.5-flash 只是官方文档当时用的示例值,平台上有哪些模型随时在变,别把它当成「这个插件只配某个模型用」的依据。TypeScript 那份的结构完全一致,只是换成 fetch 加 JSON.stringify。
请求体里的字段位置值得多说一句:plugins 和 response_format 在官方示例里是平级的顶层字段,都挂在请求体根上,plugins 不是塞进 response_format 里面的子字段。至于把位置写错之后接口会返回什么——报错还是照常返回一个未经修复的响应——这一页没有写明,别指望靠报错信息发现这个问题,还是对着示例逐字比对请求体更稳。
Linux / macOS 上把密钥放进环境变量再读,一般是:
export OPENROUTER_API_KEY="<你的密钥>"
Windows 侧不能省,两个终端的写法不一样。PowerShell:
$env:OPENROUTER_API_KEY = "<你的密钥>"
CMD:
set OPENROUTER_API_KEY=<你的密钥>
然后代码里用 os.environ["OPENROUTER_API_KEY"] 取。这一段是通用的密钥管理做法,不是 OpenRouter 官方文档的内容,写在这里只是因为把密钥硬编码进源码是排查期间最容易顺手留下的坑。以上请求体为按官方文档中的字段语义组合的示例,未经实测,以官方文档与 API 的实际响应为准。
第四步:处置后怎么验证
验证的关键不是「不报错了」,而是你得知道它到底修没修。建议这么走:
- 保留原始字符串再 parse。把
choices[0].message.content先存一份,parse 成功也存。这样你手上会积累一批「插件处理后的成品」,能看出返回过来的到底是干净 JSON 还是仍带围栏。 - 对着那五类逐个回放。你日志里攒下来的畸形样本,按上表分类。属于表里五类的,补齐三个条件后再跑同一批 prompt,看这一类还出不出现。
- 别用「失败率下降了」当验证结论。这类故障本来就是低频偶发,样本不够时降不降都说明不了问题。要么攒够量,要么直接看单条响应的形态。
还有一件事文档这一页没有说明:插件在修复失败时会返回什么——是把原始畸形内容原样透传,还是给出某种错误信号,这一页没有写。所以你的解析逻辑该有的 try/except 一个都不能去掉,别因为挂了插件就把兜底删了。
第五步:什么情况说明不是这个原因
这一步是全篇最要紧的。以下几种情况,再怎么调请求体也不会好,因为文档写明它们不在覆盖范围内:
一、响应被 max_tokens 截断。 文档在 Limitations 一节用一个 Warning 单独点了名:有些畸形 JSON 仍然是不可修复的,特别是当响应被 max_tokens 截断时,插件无法修复。文档只给了这个结论,没有解释原因。下面这段是我们把同一页的两处内容摆在一起得到的读法,不是官方说明:回头看开头那张「What Gets Fixed」的表,被列出来的五类畸形,内容本体都是完整的,缺的只是格式;而截断属于内容本身就没写完,右半边的键值对根本不存在。两者不在一个层面上,所以这一类的处置方向不是插件配置,而是回头看你的输出长度预算和 schema 体积。
顺带说一句:这一页没有写明该怎么从响应里判断是否发生了截断,没有提到任何用于判断的字段。想在自己的服务里做这个判断,得去翻 OpenRouter 关于响应结构的其它文档,别指望这一页给答案。
二、流式请求下拿到畸形 JSON。 这不是「插件没修好」,是文档写明的不适用范围。想用这个插件就得放弃流式;想要流式就得自己在客户端做拼接和容错。这是个明确的二选一,别在配置上反复试。
三、JSON 合法但内容不对。 字段缺了、类型不是你要的、枚举值不在范围内、数组该有三项只给了一项——这些的共同点是 json.loads 根本不会抛异常。文档给这个插件写明的能力只有语法修复和 markdown 提取两项,没有一个字提到它会按 json_schema 去补字段或纠正类型。这类问题属于 schema 约束与提示词的范畴,跟这个插件无关。
四、请求层面就没成功。 拿不到 choices、HTTP 状态本身就不对,那连模型输出都没有,谈不上修。这一页讲的是拿到了响应之后对 content 做的事,请求层的问题得看别的文档。
最后
这个插件的用法本身不复杂——三个条件、一个数组、一个 id。真正值得花时间的是把它的边界记住:它修的是「话说多了」和「写得不严谨」,修不了「话没说完」。把这一条刻进排查流程,你就不会在一个截断问题上反复调插件配置。
平台迭代频繁,上面涉及的字段名、插件 id 与激活条件都可能随版本变动,落地前请以官方文档最新内容为准。
本文依据 OpenRouter 官方文档(openrouter.ai/docs)于 2026-08-18 的公开内容整理。
该平台闭源,本文只复述官方文档写明的机制,不推断其内部实现;
我们没有对文中涉及的功能做过实测,因此不涉及界面外观与运行表现的任何描述。
该平台的供应商、模型与路由策略随时变动,文中不列具体供应商名单与模型清单;
价格、额度与限流的具体数值请以官方定价页与用量说明为准。
安全与合规相关做法请结合自身环境评估,本文不构成安全方案建议。