DeepSeek Harness Grok 网关 <think> 被当正文显示的排查与修法
模型没有把思考写进回答,是网关把 <think>…</think> 塞进了 delta.content,而 DSH 的适配器按原样把每段文本当正文转发。 这不是配置错误,也没有现成开关可关。下面先教你怎么确认,再给三条互不依赖的处理路径(来源)。
DeepSeek Harness 症状与判据:正文里冒出 <think>,思考区却是空的
判断关键是「同一条路由两次请求的输出表示不一致」——一次带标签混在正文,一次正常返回 reasoning_content,就说明问题在入站表示,不在模型本身。
- 打开出问题的回答,看正文开头是否直接出现
<think>与结尾标签,同时展开思考折叠区,预期看到折叠区为空或只有另一段内容。 - 在同一路由上重发一条短问题,预期有一次请求返回原生
reasoning_content,此时思考区正常、正文干净。 - 如果两次都混在正文里,也不能排除是网关固定行为,仍需走第 4 步的最小流验证。
- 用一段确定性的 Chat Completions SSE 复现:先发
{"choices":[{"index":0,"delta":{"role":"assistant","content":"<think>checking the result</think>42"},"finish_reason":null}]},再发{"choices":[{"index":0,"delta":{},"finish_reason":"stop"}]},最后data: [DONE]。预期当前版本只产出一个文本块,内容含标签原文(来源)。 - 如果第 4 步复现成功,就可以排除模型、凭据、网络三项,直接看根因一节。
- 顺手看那次回答是不是以工具调用收尾:讨论里提到工具轮次可能在
</think>到达之前就结束文本块,这种会话里标签会停在一半。
DeepSeek Harness 根因:compat 只有出站开关,入站没有对应的一侧
compat 表里有 requiresThinkingAsText 这一个出站开关,含义是「这个端点要求思考以文本标签形式发出去」;入站方向没有同名开关,所以没人负责把标签拆回来。
社区核对源码给出的链路有两段。第一段在 llm-pi-ai 的 compat 定义处,只声明了出站语义;第二段在流映射里,两个分支各自原样转发(据讨论,社区核对源码):
case 'text_delta':
yield { type: 'text-delta', index: event.contentIndex, text: event.delta }
case 'text_end':
yield { type: 'block-end', index: event.contentIndex, block: { type: 'text', text: event.content } }
这就是为什么 <think> 跨两个 SSE 记录被切开也不会被发现——两段增量落在同一个块索引上,本来就是拼接关系。
还有一个容易踩的点:text_end 携带的是整段累积文本,不是增量。任何改写 text-delta 的解析器如果不同时改写 block-end 里的 text,最终块会与流里的内容互相矛盾,前端可能先显示折叠、收尾时又弹回正文。
DSH插件路径一:在 llm/stream 瀑布里做 opt-in 解析
llm/stream 是瀑布式钩子,next() 返回可迭代的 chunk 流,插件可以包住它重构事件序列,因此解析器能只挂在有这类网关的部署上,别处完全不受影响;发布前可在 DSH Plugin Hub 对照同类插件的兼容声明。
- 新建一个插件,注册
llm/stream监听,用next()拿到下游 chunk 迭代器。预期插件加载后能在钩子里看到text-delta事件。 - 给解析器加模型白名单(例如按模型 id 或
modelOverrides项开启),默认关闭。预期未开启的模型输出逐字节不变。讨论里的提案写法是在模型条目上加一个开关:
models:
- id: grok-4.7
parseThinkTags: true # 提案字段,尚未合入,仅示意
- 实现跨 chunk 状态机:只认块开头那一段标签,标签可跨多个 chunk 到达;未闭合时一直按思考处理到块结束。预期中间被切开的
<thi+nk>也能正确识别。 - 遇到普通散文、代码围栏里引用的标签、以及第一个闭合段之后的标签,一律原样放行。预期讨论帖里「引用标签的正文不被改写」这条要求成立。
- 一个文本块拆成思考块加文本块后,重编号后续所有块索引,并同步改写
block-end的text与块类型。预期前端折叠区出现内容、正文只剩标签之外的文字。 - 如果剥完标签后回答为空,按
EMPTY_RESPONSE上报,而不是当成成功。预期空回答不会被静默记为完成。 - 用第 4 步的最小 SSE 重跑,预期得到「思考块
checking the result+ 文本块42」。 - 把会话导出成纯文本再搜
<think>。预期标签不再出现在正文,思考区能看到解析后的内容。
DeepSeek Harness 路径二:让网关把思考放进 reasoning_content
这是最省 DSH 侧改动的一条路:只要网关肯把思考写进 reasoning_content 或等价的独立字段,适配器现有的 reasoning 通道就能直接接收,前端和历史回放都不用动。
- 登录网关的管理后台或改它的转发规则,把上游返回的思考段映射到
reasoning_content字段,正文只留最终答案。预期用curl直接打网关时,delta里能看到独立的 reasoning 字段。 - 用同一条 curl 请求确认正文不再包含标签,预期整个
content只有答案文本。 - 回到 DSH 重发同一问题,展开思考折叠区,预期内容出现在折叠区、正文干净(来源)。
- 检查这条路由是否被声明了
requiresThinkingAsText。这个字段管的是出站方向,别因为它存在就以为入站也会被处理,预期你确认它并非入站开关。 - 记录网关的字段名与版本,换网关或网关升级后重测一次,预期行为不变。
DeepSeek Harness 路径三:前端渲染时折叠,不动数据
这条路只解决「看着难受」,不解决「数据里带着标签」:渲染层在 Markdown 解析前识别开头那一段并折叠,历史与导出仍是原文。
- 在消息渲染组件里,对正文做一次只匹配开头段的正则或状态机识别,预期只有块首标签被命中,行内引用不误伤。
- 把命中的段落渲染成默认折叠的区块,正文区只渲染剩余部分。预期界面观感与原生 reasoning 折叠一致。
- 明确标注这是显示层处理:复制、导出、下一轮请求仍然带标签,模型自己也会读到标签。
- 如果同时用了路径一,删掉这条渲染补丁,避免同一段内容被折叠两次。
- 观察一个工具轮会话:标签没闭合时折叠区会一直延伸到块尾,预期不会把后半段答案也藏进去——如果会,说明识别半径写大了。
默认建议先走路径二;改不了网关再上路径一;路径三只当过渡。
| 路径 | 改动位置 | 适合谁 | 主要代价 |
|---|---|---|---|
| llm/stream 解析 | DSH 插件层 | 换不了网关、又要求数据干净 | 要处理块索引与 block-end |
| 网关改字段 | 网关侧 | 自己控制网关配置 | 依赖网关实现,换家要重配 |
| 前端折叠 | 渲染层 | 只想先能读 | 数据仍带标签,不解决根本 |
默认建议先走路径二;改不了网关再上路径一;路径三只当过渡。
判断自己有没有走对,看一个指标就够了:把会话导出成纯文本,标签还在不在。还在,说明只做了路径三。
DeepSeek Harness 七个容易忽略的点
- 别在适配器外面做全局字符串替换,正文里正常引用标签会一起被吃掉。
- 标签可能永远不闭合——工具轮次会在
</think>之前结束文本块。解析器必须有 flush 路径,否则「标签外露」会变成「整段回答被吞」。 - 重编号块索引是硬要求,索引错位会让下游按块关联的内容全部错位。
- 如果解析器写对了但标签还在,先查适配器的
block-end载荷路径,而不是继续改解析器。 - 这套改动不涉及 Agent 循环,也不应重写已有会话。
- 报错归属说明:上述行号与机制来自讨论中的社区核对源码,不是官方结论,升级后需重新确认。
- 这套解析只在响应侧生效,它看不见适配器已经折叠进完成块的内容;如果标签仍然漏出,先查
block-end的载荷,而不是加大正则范围。
来源:Discussion #9877、@deepseek-ai/dsh-llm-pi-ai README。其它网关与模型排查入口见 DSH 故障排查。
常见问题
Grok 网关把思考内容写进了 delta.content,DeepSeek Harness 的 llm-pi-ai 流式映射又把每个 text delta 原样转成正文。compat 表里只有出站的 requiresThinkingAsText,没有入站解析开关,所以没有环节会拆标签。
DeepSeek Harness 可以在 llm/stream 瀑布里包住 chunk 迭代器做 opt-in 解析。解析器必须维护跨 chunk 状态、重编号 block index,并同步改写 block-end 里累积的整段文本,否则结束块会和流内容自相矛盾。
对单一路由更稳,DeepSeek Harness 不用改数据分类,前端和历史回放都不用动。代价是你依赖网关可配置;换一家网关可能又回到混在 content 里的老样子,需要重新确认字段名。
不能,DeepSeek Harness 前端折叠只改显示不改数据。会话历史、导出内容和下一轮请求里仍然是带标签的正文,模型自己也会读到这些标签。适合当临时提高可读性的补丁,不能替代入站解析。
相关术语
- compat 表(llm-pi-ai)
- compat 表是 llm-pi-ai 用来描述某个 OpenAI 兼容端点方言差异的开关集合。它按方向区分能力,出站用 requiresThinkingAsText 表示思考必须以文本标签发送,入站目前没有对应开关。— https://github.com/deepseek-ai/deepseek-harness/discussions/9877
- text-delta / block-end
- text-delta 是流式响应里的增量文本事件,block-end 是内容块结束时携带完整文本的收尾事件。二者在 llm-pi-ai 的流映射中一一对应,改写增量就必须同步改写收尾事件里的整段文本。— https://github.com/deepseek-ai/deepseek-harness/discussions/9877
- llm/stream 瀑布
- llm/stream 是 LLM 请求链路上的瀑布式钩子,next() 返回可迭代的响应 chunk 流。插件可以包住这个迭代器重构事件序列,无需改动核心适配器。— https://github.com/deepseek-ai/deepseek-harness/discussions/9877
来源
- [Bug] Grok gateway leading <think> content is rendered as answer text· deepseek-ai/deepseek-harness Discussions
- @deepseek-ai/dsh-llm-pi-ai README· deepseek-ai