DeepSeek Harness Grok 网关 <think> 被当正文显示的排查与修法

故障排查发布于 2026-10-20作者: DeepSeek Plugin 插件市场
DeepSeek HarnessGrok 网关think 标签流式解析故障排查
DeepSeek Harness 接 Grok 网关时,<think> 被塞进 delta.content,llm-pi-ai 当正文渲染、思考区为空。本文给 llm/stream 解析、网关改 reasoning_content、前端折叠三条路,附最小 SSE 复现。

模型没有把思考写进回答,是网关把 <think>…</think> 塞进了 delta.content,而 DSH 的适配器按原样把每段文本当正文转发。 这不是配置错误,也没有现成开关可关。下面先教你怎么确认,再给三条互不依赖的处理路径(来源)。

DeepSeek Harness 症状与判据:正文里冒出 <think>,思考区却是空的

判断关键是「同一条路由两次请求的输出表示不一致」——一次带标签混在正文,一次正常返回 reasoning_content,就说明问题在入站表示,不在模型本身。

  1. 打开出问题的回答,看正文开头是否直接出现 <think> 与结尾标签,同时展开思考折叠区,预期看到折叠区为空或只有另一段内容。
  2. 在同一路由上重发一条短问题,预期有一次请求返回原生 reasoning_content,此时思考区正常、正文干净。
  3. 如果两次都混在正文里,也不能排除是网关固定行为,仍需走第 4 步的最小流验证。
  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]。预期当前版本只产出一个文本块,内容含标签原文(来源)。
  5. 如果第 4 步复现成功,就可以排除模型、凭据、网络三项,直接看根因一节。
  6. 顺手看那次回答是不是以工具调用收尾:讨论里提到工具轮次可能在 </think> 到达之前就结束文本块,这种会话里标签会停在一半。

DeepSeek Harness 根因:compat 只有出站开关,入站没有对应的一侧

compat 表里有 requiresThinkingAsText 这一个出站开关,含义是「这个端点要求思考以文本标签形式发出去」;入站方向没有同名开关,所以没人负责把标签拆回来。

社区核对源码给出的链路有两段。第一段在 llm-pi-ai 的 compat 定义处,只声明了出站语义;第二段在流映射里,两个分支各自原样转发(据讨论,社区核对源码):

ts
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 对照同类插件的兼容声明。

  1. 新建一个插件,注册 llm/stream 监听,用 next() 拿到下游 chunk 迭代器。预期插件加载后能在钩子里看到 text-delta 事件。
  2. 给解析器加模型白名单(例如按模型 id 或 modelOverrides 项开启),默认关闭。预期未开启的模型输出逐字节不变。讨论里的提案写法是在模型条目上加一个开关:
yaml
models:
  - id: grok-4.7
    parseThinkTags: true   # 提案字段,尚未合入,仅示意
  1. 实现跨 chunk 状态机:只认块开头那一段标签,标签可跨多个 chunk 到达;未闭合时一直按思考处理到块结束。预期中间被切开的 <thi + nk> 也能正确识别。
  2. 遇到普通散文、代码围栏里引用的标签、以及第一个闭合段之后的标签,一律原样放行。预期讨论帖里「引用标签的正文不被改写」这条要求成立。
  3. 一个文本块拆成思考块加文本块后,重编号后续所有块索引,并同步改写 block-end 的 text 与块类型。预期前端折叠区出现内容、正文只剩标签之外的文字。
  4. 如果剥完标签后回答为空,按 EMPTY_RESPONSE 上报,而不是当成成功。预期空回答不会被静默记为完成。
  5. 用第 4 步的最小 SSE 重跑,预期得到「思考块 checking the result + 文本块 42」。
  6. 把会话导出成纯文本再搜 <think>。预期标签不再出现在正文,思考区能看到解析后的内容。

DeepSeek Harness 路径二:让网关把思考放进 reasoning_content

这是最省 DSH 侧改动的一条路:只要网关肯把思考写进 reasoning_content 或等价的独立字段,适配器现有的 reasoning 通道就能直接接收,前端和历史回放都不用动。

  1. 登录网关的管理后台或改它的转发规则,把上游返回的思考段映射到 reasoning_content 字段,正文只留最终答案。预期用 curl 直接打网关时,delta 里能看到独立的 reasoning 字段。
  2. 用同一条 curl 请求确认正文不再包含标签,预期整个 content 只有答案文本。
  3. 回到 DSH 重发同一问题,展开思考折叠区,预期内容出现在折叠区、正文干净(来源)。
  4. 检查这条路由是否被声明了 requiresThinkingAsText。这个字段管的是出站方向,别因为它存在就以为入站也会被处理,预期你确认它并非入站开关。
  5. 记录网关的字段名与版本,换网关或网关升级后重测一次,预期行为不变。

DeepSeek Harness 路径三:前端渲染时折叠,不动数据

这条路只解决「看着难受」,不解决「数据里带着标签」:渲染层在 Markdown 解析前识别开头那一段并折叠,历史与导出仍是原文。

  1. 在消息渲染组件里,对正文做一次只匹配开头段的正则或状态机识别,预期只有块首标签被命中,行内引用不误伤。
  2. 把命中的段落渲染成默认折叠的区块,正文区只渲染剩余部分。预期界面观感与原生 reasoning 折叠一致。
  3. 明确标注这是显示层处理:复制、导出、下一轮请求仍然带标签,模型自己也会读到标签。
  4. 如果同时用了路径一,删掉这条渲染补丁,避免同一段内容被折叠两次。
  5. 观察一个工具轮会话:标签没闭合时折叠区会一直延伸到块尾,预期不会把后半段答案也藏进去——如果会,说明识别半径写大了。

默认建议先走路径二;改不了网关再上路径一;路径三只当过渡。

路径改动位置适合谁主要代价
llm/stream 解析DSH 插件层换不了网关、又要求数据干净要处理块索引与 block-end
网关改字段网关侧自己控制网关配置依赖网关实现,换家要重配
前端折叠渲染层只想先能读数据仍带标签,不解决根本

默认建议先走路径二;改不了网关再上路径一;路径三只当过渡。

判断自己有没有走对,看一个指标就够了:把会话导出成纯文本,标签还在不在。还在,说明只做了路径三。

DeepSeek Harness 七个容易忽略的点

  1. 别在适配器外面做全局字符串替换,正文里正常引用标签会一起被吃掉。
  2. 标签可能永远不闭合——工具轮次会在 </think> 之前结束文本块。解析器必须有 flush 路径,否则「标签外露」会变成「整段回答被吞」。
  3. 重编号块索引是硬要求,索引错位会让下游按块关联的内容全部错位。
  4. 如果解析器写对了但标签还在,先查适配器的 block-end 载荷路径,而不是继续改解析器。
  5. 这套改动不涉及 Agent 循环,也不应重写已有会话。
  6. 报错归属说明:上述行号与机制来自讨论中的社区核对源码,不是官方结论,升级后需重新确认。
  7. 这套解析只在响应侧生效,它看不见适配器已经折叠进完成块的内容;如果标签仍然漏出,先查 block-end 的载荷,而不是加大正则范围。

来源:Discussion #9877、@deepseek-ai/dsh-llm-pi-ai README。其它网关与模型排查入口见 DSH 故障排查。

常见问题

DeepSeek Harness 接 Grok 网关时,回答开头出现 <think> 标签、思考折叠区为空是什么原因?

Grok 网关把思考内容写进了 delta.content,DeepSeek Harness 的 llm-pi-ai 流式映射又把每个 text delta 原样转成正文。compat 表里只有出站的 requiresThinkingAsText,没有入站解析开关,所以没有环节会拆标签。

不改 DeepSeek Harness 核心,只装插件能让 Grok 的 <think> 回到思考区吗?

DeepSeek Harness 可以在 llm/stream 瀑布里包住 chunk 迭代器做 opt-in 解析。解析器必须维护跨 chunk 状态、重编号 block index,并同步改写 block-end 里累积的整段文本,否则结束块会和流内容自相矛盾。

让网关把思考改到 reasoning_content,比在 DeepSeek Harness 侧解析更稳吗?

对单一路由更稳,DeepSeek Harness 不用改数据分类,前端和历史回放都不用动。代价是你依赖网关可配置;换一家网关可能又回到混在 content 里的老样子,需要重新确认字段名。

前端渲染时把 DeepSeek Harness 的 <think> 折叠起来,能当成正式修复吗?

不能,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

来源