一个小字符串是怎么打爆 OMP 自动压缩的
太长不看
OMP 是一个 AI 编程助手。你跟它聊得越久,对话越长。AI 一次能处理的文字有上限,所以到了一定长度,OMP 会自动把旧对话压缩成一段摘要,继续聊。
压缩的过程是让 AI 重新读一遍旧对话,写个总结。问题出在 OMP 怎么排版旧对话:它用了一种叫 Harmony 的格式来序列化,其中 AI 之前的思考内容被包上了 <|channel|>analysis 这样的标记。这个标记是 OMP 自己加的格式,不是 AI 写的,也不是对话里本来就有的。
GitHub Copilot 的 gpt-5.6 系列模型拒绝处理包含这个标记的请求。为什么只有 gpt-5.6 系列拦、其他模型不拦,没有从官方得到确认。从行为看,它可能把这个标记识别为不该出现在用户输入里的内部协议符号。哪怕请求里只有这一个字符串、什么都没有,也会被拒。
结果就是压缩失败,会话卡住,用户得手动换模型或清空历史才能继续。
十几个字符的字符串,直接让 OMP compaction 400 了。
字符串是这个:
1 | <|channel|>analysis |
场景是 OMP 的自动压缩。会话太长以后,OMP 会把历史对话压成一份摘要,继续塞回上下文里。正常情况下,这件事不该惊动用户。结果这次直接炸了:
1 | Compaction failed: Summarization failed: 400 |
一开始看起来像凭证问题。再看 raw request,味道不对。
先定义问题是啥
不是模型不会回答,也不是网络问题。
OMP 在做 compaction summarization 的时候,把历史 transcript 当普通 input_text 发给 Copilot Responses API,里面出现了 Harmony 的 analysis channel marker:
1 | <|channel|>analysis |
Copilot 的 gpt-5.6-* 三个模型直接拒绝。
要拆三个问题:最小 payload 能不能单独触发?是不是某个环节加了料?这个字符串从哪来的?
最小 payload
1 | { |
直接发请求,返回 400:
1 | { |
不需要完整 transcript,不需要上百 KB 上下文,单独这个字符串就够了。
边界测试的结果很明确。下面这些可以过:
1 | <|channel|>final |
下面这些会被拦:
1 | <|channel|>analysis |
这就不像普通内容过滤了。它更像一个针对 Harmony / hidden reasoning channel 的 guard。
推断需要直连上游来坐实。
直连上游验证
如果只在一个环境复现,锅可能在任何一层。于是直接打 Copilot 上游:
1 | https://api.githubcopilot.com/v1/responses |
结果:
| model | result |
|---|---|
gemini-2.5-pro |
400, unsupported Responses API |
gemini-3.5-flash |
400, unsupported Responses API |
gpt-5-mini |
200 |
gpt-5.3-codex |
200 |
gpt-5.4 |
200 |
gpt-5.4-mini |
200 |
gpt-5.5 |
200 |
gpt-5.6-luna |
400, Request blocked |
gpt-5.6-sol |
400, Request blocked |
gpt-5.6-terra |
400, Request blocked |
mai-code-1-flash-picker |
200 |
Gemini 那两条是另一个问题:它们本来就不支持 Responses API。真正相关的是 gpt-5.6-luna、gpt-5.6-sol、gpt-5.6-terra。
这三条直连上游也 400。
这个字符串从哪里来?
<|channel|>analysis 为什么会出现在 OMP payload 里?
它不是用户手写的,也不是 advisor 文本随机带进去的。它来自 OMP 自己的 transcript serialization。
上游源码路径是这些:
packages/agent/src/compaction/compaction.tspackages/agent/src/compaction/utils.tspackages/catalog/src/identity/dialect.tspackages/ai/src/dialect/harmony.ts
compaction 里会把历史消息转换成 LLM message,然后序列化成对话文本:
1 | const llmMessages = (options?.convertToLlm ?? defaultConvertToLlm)(currentMessages); |
serializeConversation() 拿到 dialect 后调用对应 renderer 把历史消息转成 transcript 文本。
gpt-5.6-* 选哪个 dialect?preferredDialect() 对 OpenAI 和 gpt-oss family 返回 "harmony",其余走 fallback。
再看 Harmony dialect 怎么渲染 assistant thinking:
1 | function renderThinking(text: string): string { |
翻成人话就是:
1 | <|start|>assistant<|channel|>analysis<|message|>...<|end|> |
所以这不是泄漏了什么奇怪字符串。OMP 有意用目标模型的 native dialect 渲染历史 assistant thinking,目的是保留不同类型的历史内容。
问题在最后一步:这段渲染后的 transcript 是当普通 input_text 发给 Copilot gpt-5.6-* 做 summarization 的。模型看到的不是结构化 transcript,而是普通用户输入里冒出来一个 <|channel|>analysis。
Copilot 上游把它拦了。
谁的锅?
返回 400 的是 GitHub Copilot upstream,直连复现过。但让 marker 进入 compaction input 的是 serialization 路径本身。
Harmony marker 在 OMP 里合法,gpt-5.5 也不拦。但拼在一起就炸了。
那应该怎么修?
最小修法在 compaction summarization 这条路径:不要把 Harmony control tokens 原样作为 plain input 发给 Copilot gpt-5.6-*。关掉 Harmony 或禁止 assistant thinking 都没必要。
几种可能的做法:
- 对 Copilot
gpt-5.6-*的 compaction input 改用 XML/plain transcript。 - 在 compaction serialization 里 escape Harmony control tokens,比如把
<|channel|>analysis变成不可被上游识别的普通文本。 - 把 assistant thinking demote 成普通安全标签,比如
<thinking>...</thinking>,不要用 Harmony channel marker。
这里也有边界:主对话的 tool-calling dialect 可以通过 PI_DIALECT 一类配置影响;但 compaction 这段代码查到的是直接用 preferredDialect(model.id),目前没看到用户级 opt-out。
所以用户侧 workaround 更现实:把 compaction 用的 fallback/smol model 临时换到不会拦的模型,例如前面测过 gpt-5.5 对最小字符串返回 200。
调 agent 的 context compaction,除了看摘要 prompt 写得好不好,还要看摘要输入里有没有把上一轮模型协议的控制 token 当普通文本塞回下一轮。
已把发现补到 OMP issue:https://github.com/can1357/oh-my-pi/issues/5184#issuecomment-4946123954。