LLM 输出文本修复分层设计
背景
LLM API 响应存在两类常见的"脏输出"问题,需要在不同的系统层级处理:
- 协议层泄漏:模型将本应走专用通道的内部标记(如 DeepSeek DSML tool_call 标记)错误地输出到
content字段,导致结构化信息丢失或裸标记出现在用户侧 - 渲染层错误:模型输出的 Markdown 存在格式错误(如 GFM 表格分隔行列数与表头不一致),导致 remark-gfm、KaTeX 等渲染器解析失败
这两类问题的性质不同,修复位置和策略也应不同。
责任分层
┌──────────────────────────────────────────────────┐
│ plugin-agent │
│ stripLeakedSpecialTokens(content) │ ← 剥协议标记(经 OpenAI 兼容接口时唯一的一道)
└────────────────────┬─────────────────────────────┘
│ ChatResponse { content, toolCalls }
┌────────────────────▼─────────────────────────────┐
│ plugin-llm-deepseek / plugin-llm-openai / ... │
│ (LLM 适配器) │ ← 协议层修复(厂商特定)
└────────────────────┬─────────────────────────────┘
│ SSE / HTTP
┌────────────────────▼─────────────────────────────┐
│ DeepSeek / OpenAI / Ollama API │
└──────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────┐
│ plugin-webui-client │
│ preprocessLaTeX → fixGfmTables(...) │ ← 渲染层修复(只改显示)
└──────────────────────────────────────────────────┘层 1:LLM 适配器(plugin-llm-deepseek 等)
职责:处理本厂商专有协议的"协议层泄漏"。
- 检测泄漏,将
content中的标记残渣剥离干净,记一条 warn - 对外暴露的
ChatResponse中,content已是干净文本,toolCalls只取服务端给的
不做的事:
- 不把漏出的标记解析成工具调用去执行:服务端没认出的调用,正文里那段只是文字,照它执行等于把文字当动作
- 不做 GFM 修复(与厂商无关,不是适配器关心的事)
- 不发明自己的剥离 regex(直接复用
@aalis/util-text-normalize的stripLeakedSpecialTokens)
层 2:util-text-normalize(@aalis/util-text-normalize)
职责:提供无副作用的纯函数文本处理工具集,供适配器层、agent 层、webui 层复用。
| 函数 | 用途 | 设计约束 |
|---|---|---|
stripLeakedSpecialTokens | 剥离 DSML 等泄漏的特殊 token 标记([||]+ ... DSML ... 等) | 不返回 ToolCall[];不 import 业务类型 |
fixGfmTables | 修复 GFM 表格分隔行与表头列数不一致 | 跳过代码块,仅修正明确错误 |
设计约束:该包不能依赖 @aalis/schema-message 等业务包,以便在 webui 客户端(browser bundle)中也能使用。返回类型只能是 string 或 { string, boolean }。
层 3:plugin-agent
职责:每次模型调用拿到完整的 ChatResponse 后调 stripLeakedSpecialTokens,剥掉漏进正文的协议标记:只剥不执行,落库的正文与时间线都过它。经 OpenAI 兼容接口时这是唯一的一道。
- 不修表格:外发与落库的是模型的原文,表格只在渲染端修(层 4)
层 4:plugin-webui-client(前端)
职责:在客户端渲染前跑 fixGfmTables:模型常多打或少打一根竖线,渲染器遇到列数不等会把整张表退成纯文字。只改显示,存下的原文不动;流式输出时每次渲染同样修。
DeepSeek DSML 泄漏的完整处理流程
背景
DeepSeek V3.2/V4 使用 DSML (DeepSeek Markup Language) 表达 tool_calls:
<|DSML|tool_calls>
<|DSML|invoke name="web_search">
<|DSML|parameter name="query" string="true">keyword</|DSML|parameter>
</|DSML|invoke>
</|DSML|tool_calls>(| 为 U+FF5C 全角竖线)
已知 bug:模型偶发输出双竖线变体 <||DSML||...>,服务端严格匹配失败,整段泄漏到 content,tool_calls 字段为空。
流式路径(chatStream())
chunk 到来
│
├─ [还没检测到] 尾缓冲 + delta.content 作为本帧候选
│ └─ [||]+ 检测到 DSML 起始 → dsmlDetected = true,记 warn
│ └─ 输出候选里 DSML 之前的部分(如有)
│
├─ [已检测到] 之后的正文不再 emit
│
└─ [DONE 或流意外结束]
└─ yield { done: true, toolCalls: 服务端给的工具调用 }
agent 收到:
content = DSML 之前的文本(干净,可能为空)
toolCalls = 服务端给的(服务端没解析出来时这次的工具调用丢了)
agent 的 stripLeakedSpecialTokens(content) = no-op跨帧缓冲(pendingTail):检测到 < 或 <|...(可能是 DSML 起始的前缀)时,暂存到下一帧再判,避免半截特殊 token 已被 emit 后无法收回。
非流式路径(chat())
API 响应 JSON
choices[0].message.content = "前缀文本<|[|]DSML[|]|...>..." ← 泄漏
choices[0].message.tool_calls = [] ← 服务端解析失败
│
├─ stripLeakedSpecialTokens(content)
│ ├─ hadLeak = true → 记 warn(写明服务端没给 tool_calls,这次的工具调用丢了)
│ └─ sanitized = "前缀文本" ← 剥离后的干净文本
│
└─ result.content = sanitized ← 干净文本
result.toolCalls = 服务端给的(这里没有)
agent 收到:
content = sanitized(DSML 已剥离)
toolCalls = undefined
agent 的 stripLeakedSpecialTokens(content) = no-op各包依赖关系
plugin-agent
└── @aalis/util-text-normalize (stripLeakedSpecialTokens)
plugin-llm-deepseek
└── @aalis/util-text-normalize (stripLeakedSpecialTokens)
plugin-webui-client
└── @aalis/util-text-normalize (fixGfmTables)
util-text-normalize
└── (无业务依赖)未来扩展指南
新增 LLM provider 有类似的特殊 token 泄漏(如 Qwen <|im_tool_call|>、Llama <|python_tag|>):
- 不恢复成工具调用:漏进正文的标记只剥离,工具调用只取服务端给的,适配器不自己解析标记去执行
- 剥离文本:扩展
util-text-normalize的stripLeakedSpecialTokens中的 regex,不要新建stripQwenTokens等并列函数,避免调用方认知碎片化 - GFM / 渲染修复:只加进渲染端的预处理(webui-client 的
preprocessLaTeX),不改存下的原文
扩展 util-text-normalize 时的约束:
- 只接受/返回
string,不引入业务包类型 - 所有函数必须是纯函数,无副作用,可在 Node.js 和 browser 环境运行
- 跳过 fenced code block(
```内)和 inline code(`内)以避免误改代码示例