OpenRouter 把你的消息改写了:message transforms 在你不知道时做了什么

2026-08-18

先说清楚这篇要解决的场景:你把一段多轮对话发给 OpenRouter,模型的回答里明显缺了中间某几轮的信息,但你的请求体里那几条消息是在的,你也没有做任何裁剪。这种「我发出去的和模型看到的不是同一份」的情况,官方文档里有对应机制,页面叫 Message Transforms(openrouter.ai/docs/guides/features/message-transforms)。

要先适应一件事:同一个东西在官方文档里有三个名字。页面标题叫 Message Transforms,正文里讲的是一个叫 context-compression 的 plugin,Router Metadata 页的响应示例里它的 engine 字段值是 middle-out,Advisor 页则直接称它为 middle-out transform。你按其中一个词搜文档,很可能漏掉另外两处。排查前把这三个词都记下来。

现象:不报错,但内容少了

有两类表现值得区分。

一类是请求直接失败。官方文档《Errors and Debugging》页的错误表里写明,context_length_exceeded 的语义是「输入与输出 token 合计超出了模型的上下文窗口」,HTTP 状态是 400。Message Transforms 页也写明:如果没有启用 context compression 而总 token 超出模型上下文长度,请求会失败,错误信息会提示你要么缩短内容,要么启用 context compression。

另一类更麻烦:请求成功了,内容却少了。《Errors and Debugging》页里有一张「错误码转换」表,写明 context_length_exceededmax_tokens_exceededtoken_limit_exceededstring_too_long 这四类会被转换成成功响应,finish_reasonlength。也就是说,「HTTP 200 且没有异常」并不能证明你的消息完整送达。排查这类问题时,别拿状态码当证据。

这里有个位置细节值得先记住:这张转换表在文档里是挂在 Responses API(/api/v1/responses)那一节下的,与它并列的还有 Chat Completions、Anthropic Messages 各自的错误格式小节。所以别默认这套转换对每条路由都一模一样地成立,你走哪条路由就去看哪一节。

第一步:确认压缩确实跑过

有两个官方文档写明的可执行动作,都不需要你猜。

动作一,打开 router metadata。 Router Metadata 页写明这是一个按请求 opt-in 的开关:发送请求头 X-OpenRouter-Metadata,值为 enabled。文档给出的取值表只有两个值,enableddisabled,大小写不敏感;任何其它值(包括拼错的、空字符串、未知级别)都回落到 disabled,不带这个头时的默认行为也是 disabled。文档另写明旧的头名 X-OpenRouter-Experimental-Metadata 仍然被接受,建议迁移到新名字——如果你的代码里还是旧名,这条别忽略。

开启后,成功响应里会多出 openrouter_metadata 对象,其中的 pipeline 数组记录了每一个实质影响了这次请求的 plugin。文档给的示例里,压缩这一段长这样:

"pipeline": [
  {
    "type": "context_compression",
    "name": "context-compression",
    "data": {
      "engine": "middle-out",
      "input_type": "messages",
      "original_count": 42,
      "compressed_count": 30
    }
  }
]

(以上为官方文档中的示例响应片段,其中的计数是文档写的示例值,不代表你的请求会得到同样的数字。)

这张表怎么读,文档的《Pipeline Stages》一节写明了:context_compression 这个 type 告诉你「用了哪个 engine、输入类型是 messages 还是 prompt、压缩前后的计数」。关键在下一句——一个 plugin 只有真正运行时才会发出 stage,空转的 plugin(例如发现输入本来就装得下的 context compression)会被略过。所以判定规则很干脆:pipeline 里有这一条,就是压过;没有这一条,这次请求没压。

有两处例外要记牢,否则会误判。文档写明缓存命中的响应从不包含 openrouter_metadata,流式与非流式的缓存重放都会把这个字段剥掉;文档自述这是有意为之,因为你在缓存未命中时看到的 metadata 不一定反映产出该缓存内容的那次路由。另外错误响应也可以带 metadata,位置在错误信封的顶层,与 error 同级,同样需要那个请求头。

动作二,看真正发给上游的请求体。 《Errors and Debugging》页写明 OpenRouter 提供了一个 debug 选项,用来查看实际发送给上游供应商的请求体,支持 /api/v1/chat/completions/api/v1/responses 两条路由。它的形状是:

type DebugOptions = {
  echo_upstream_body?: boolean; // If true, returns the transformed request body sent to the provider
};

文档示例里明确标注了一句:debug 只在 streaming 下工作(示例中 stream: true 那一行的注释就是 Debug only works with streaming)。Chat Completions 侧从流里取 parsed.debug.echo_upstream_body,Responses API 侧则是判断 parsed.type === 'response.debug'。把这个 body 里的消息条数与你发出去的比一比,比任何推测都直接。

这条路有两条文档写明的限制要一起记住。一是上面说的只在 streaming 下生效,非流式请求会忽略这个参数,不报错也不给你 debug 数据——如果你非流式调了半天什么都没看到,不是配错了,是文档就这么定的。二是文档挂了一条明确警告:debug 不应该用在生产环境,它可能把请求里本不打算被别处看到的敏感内容原样返回出来;文档另写明会尽最大努力自动脱敏掉潜在敏感或噪声数据,但同一段又强调该选项不是为生产设计的。把它当成排查时临时打开、查完就关的开关,别写进常驻配置。

第二步:搞清它是被什么触发的

这是本文的落点。按官方文档,能让 context compression 跑起来的路径有三条,关掉的方式各不相同。

路径一,你自己在请求里开的。 Message Transforms 页给的写法是:

{
  plugins: [{ id: "context-compression" }], // Compress prompts that are > context size.
  messages: [...],
  model // Works with any model
}

路径二,端点自身默认开启。 Message Transforms 页有一条 Note 写明:上下文长度在文档给定阈值及以下的所有 OpenRouter 端点,会默认使用 context compression。具体阈值以官方文档页为准(这类数值改起来没有成本,本文不复述)。这条是最容易被忽略的——你什么都没配,它照样在跑。

路径三,账号或组织的默认配置。 Plugins 页(openrouter.ai/docs/guides/features/plugins)写明,plugin 除了按请求启用,也可以在 Plugins 设置页配成对所有 API 请求生效的默认值,官方文档写明的路径是 Settings > Plugins,页面上有开关与配置按钮,还有一个叫 “Prevent overrides” 的选项。优先级文档也写明了:请求级设置优先于账号默认;但一旦某个 plugin 打开了 “Prevent overrides”,单次请求就无法再关闭或修改它的配置。文档还写明在组织里,Plugins 设置页只有 admin 能访问。

所以如果你按下面的写法关了却发现它还在跑,先去确认这一项,而不是怀疑请求体写错了。

关闭的写法,Message Transforms 页里是逐字给出的:

plugins: [{"id": "context-compression", "enabled": false}]

Plugins 页把这条推广成了通用规则:账号默认里开着的 plugin,可以在单次请求里传 "enabled": false 关掉。

把关闭与观测放在一起,一次请求就能同时验证两件事:

curl https://openrouter.ai/api/v1/chat/completions \
  -H "Authorization: Bearer <YOUR_API_KEY>" \
  -H "Content-Type: application/json" \
  -H "X-OpenRouter-Metadata: enabled" \
  -d '{
    "model": "openai/gpt-4o-mini",
    "messages": [{ "role": "user", "content": "Hello" }],
    "plugins": [{"id": "context-compression", "enabled": false}]
  }'

以上为按官方文档中的字段语义组合的示例,未经实测,以官方文档与 API 的实际响应为准。其中的 model slug 取自官方文档当时的示例值,平台上有哪些模型与端点随时在变,不要当清单用。

Windows 侧要注意:上面这段是 Linux/macOS 的 shell 写法,单引号包住 JSON 只在 bash/zsh 下成立。在 PowerShell 或 CMD 里,单引号与内层双引号的转义规则不同,直接照抄大概率会得到一个格式错误的请求体。把 JSON 写进文件再用 -d "@body.json",或者干脆用文档里的 TypeScript / Python 示例发请求,可以绕开引号问题。(这一段是通用的命令行常识,不是 OpenRouter 官方文档的内容。)

第三步:知道它开着的时候在做什么

关掉之前,先确认你是不是真的要关。文档写明的行为有三层,值得分开看。

第一层,删哪儿。 plugin 的做法是从 prompt 的中间移除或截断消息,直到装进模型的上下文窗口。文档自述了理由,并附了一篇论文链接:大模型对序列中间部分的注意力更少。

第二层,消息条数也算。 文档写明有时候卡住的不是 token 长度而是消息条数——Anthropic 的 Claude 模型对单次请求的消息条数有上限(具体数值见官方文档)。超过该上限且启用了 context compression 时,plugin 会保留对话开头的一半与结尾的一半。Advisor 页的说法与此一致:advisor 的跨请求记忆若超出其模型上下文窗口,会用 middle-out transform 修剪中间、保留最旧与最新的若干轮交互。(需要说明的是,Advisor 所属的 server tools 在官方文档里标注为 beta,文档写明 API 与行为可能变化。)

第三层,它会改变选模型的口径。 这一条最容易被漏掉:文档写明启用 context compression 后,OpenRouter 会先去找上下文长度至少达到你总需求(输入 + 补全)一半的模型;如果没有模型满足这个条件,就回落到可用上下文长度最大的那个模型。换句话说,开着压缩不只是「少发几条消息」,候选面本身就跟着变了。你如果对具体走哪一档端点有要求,这一点得算进去。

第四步:处置后怎么验证

带上 X-OpenRouter-Metadata: enabled 重发同一个请求,看 pipeline 数组里还有没有 typecontext_compression 的那一项。按文档写明的「空转不发 stage」规则,这一项消失就说明压缩没有运行。想再确认一层,就加上 debug: { echo_upstream_body: true }(记得同时 stream: true),直接数上游 body 里的消息条数。

另一个反向信号:关闭之后,如果内容确实超出模型上下文,按文档所述请求会以 context_length_exceeded 失败,错误信息会提示缩短内容或启用 context compression。收到这个错误,恰恰证明压缩确实已经关掉了。但别忘了前面那张转换表——按文档,在 Responses API 那一节的规则下这类错误会以成功响应加 finish_reason: length 的形式回来,所以验证时同时看 finish_reason,不要只看有没有抛异常

什么情况说明不是这个原因

以下几种,问题都不在 context compression 上:

  • 只输出图片的图像生成模型。 文档写明这类模型会自动跳过压缩,目的是保住 image-to-image 请求里的参考图;文档说明若压缩了会截断多段消息内容并丢掉输入的 image_url 部分。这类请求丢内容,得往别处查。
  • 但同时输出文本与图像的多模态模型不在豁免之列。 文档写明它们有真实的文本上下文窗口,仍然会走压缩,并举了 Gemini、gpt-image 作为例子。别把上一条推广过头。
  • 丢的是开头或结尾的内容。 压缩明确是从中间下手;开头和结尾一起没了,方向不对。
  • 报的是 max_tokens_exceeded / string_too_long / token_limit_exceeded 按文档的错误表,这三者分别指:生成因触及 max_tokens(或 max_completion_tokens)而停止、请求中单个字符串字段超出供应商的每字段字符上限、以及触及 OpenRouter 侧的 token 预算(文档举的例子是基于信用额度的上限)。尤其 string_too_long——那是一条消息自己太长,压缩处理的是消息之间的取舍,救不了它。
  • pipeline 里是别的 stage。 文档列出的 stage 类型还有 guardrailpluginserver_toolsresponse_healing。内容被改动未必是压缩干的,先按 type 对号入座。文档也提醒该列表会持续增加,未知的 stage 类型按不透明对象处理。
  • 响应里根本没有 openrouter_metadata 先排除三种情况:没带那个请求头、头的值不是 enabled、或者这次是缓存命中。文档写明缓存命中不带这个字段——观测不到不等于没发生,这种情况下上面的判定规则不成立,得换 debug 那条路。

最后提醒一句:该平台迭代频繁,文中涉及的字段名、请求头与默认行为随版本变动,以官方文档最新内容为准。


本文依据 OpenRouter 官方文档(openrouter.ai/docs)于 2026-08-18 的公开内容整理。 该平台闭源,本文只复述官方文档写明的机制,不推断其内部实现我们没有对文中涉及的功能做过实测,因此不涉及界面外观与运行表现的任何描述。 该平台的供应商、模型与路由策略随时变动,文中不列具体供应商名单与模型清单; 价格、额度与限流的具体数值请以官方定价页与用量说明为准。

想系统学会用 AI?报名体系课或加入会员,照着学、照着用。