第30章:LLM 客户端与输出解析器
本章把 internal/pkg/llm 的另一半讲完:Chat 的三种形态(普通/结构化/思考模式)、ChatOptions 的参数设计,以及 parse 系列容错解析器(ParseIndices、parse_clips、parse_ai_slice)——「模型输出不可信」这个前提如何转化为具体的解析防御工事。
流程
LLM 客户端(client.go,第 2 章已见构造)的调用面有四个方法,按「对输出确定性的要求」分层:
ChatCompletions:普通调用,返回完整响应体 RawMessage;ChatStructured(服务接口层):temperature=0 + response_format=json_object + 关闭思考模式——用于 ASR 后处理这类需要严格 JSON 的场景(接口注释原文);ChatThinking:显式enable_thinking=true——AI 切片需要更深推理(DashScope/Qwen 的混合思考模式开关);Chat:兜底普通对话。
单次请求的流程:校验 APIKey 与 messages 非空 → 组装 chatRequest(model/messages/temperature/response_format/enable_thinking 按需 omitempty)→ POST /chat/completions → 解析 chatResponse.Choices[0].Message.Content → 上游在 body 里嵌 error 结构时转错误(OpenAI 兼容协议的常见变体,Error *struct{Message,Type,Code})。
解析器(parse_indices.go 等)负责把 Content 文本变成 Go 结构:去 markdown 代码块包裹(json /JSON / ``)→ 尝试直接 Unmarshal → 失败则extractJSONArray` 从混杂文本中手工扫描出第一个平衡的 JSON 数组(处理字符串内引号、转义、嵌套深度的完整状态机)→ 再解析 → 全部失败才报错(错误信息带 256 字符截断的原文)。
实现
实现里最值钱的是解析状态机(extractJSONArray):LLM 经常无视「只输出 JSON」的要求,输出「好的,以下是结果:[2,5,9] 希望有帮助」——解析器必须能从垃圾话里捞出数组。扫描器维护三个状态:depth(括号深度)、inString(是否在字符串内)、escape(前一字符是否反斜杠);只有 depth 归零才是一个完整 JSON 值的边界。这个 80 行的手写扫描器比正则可靠得多(正则处理嵌套与转义是出了名的坑)。
其他细节:
- 三种思考模式映射为三个方法:调用方声明意图(要严格还是要深思),客户端翻译为参数组合——思考开关是 DashScope 特有的顶层字段
enable_thinking,被封装在协议适配层内,换供应商时只改 client.go。 - response_format json_object:OpenAI 协议的官方 JSON 模式,配合 temperature=0 把随机性压到最低;结构化场景(解析索引)用它,创意场景(写标题)不用。
- 错误内嵌在响应体:部分兼容实现 200 状态也带 error 字段——客户端显式检查并转换,避免「成功状态里藏着失败」。
parse_clips.go/parse_ai_slice.go与 ParseIndices 同构,分别解析 clips 数组与 AI 切片的复合结构(indices + title + description + topics),共享「剥代码块 → 直接解析 → 扫描兜底」三段式。- 每个解析器配全量表驱动测试:纯 JSON、markdown 包裹、前后噪声、截断、空串——LLM 输出的形态空间被测试用例固化。
📌 设计决策
- 不用 Function Calling / Tool Use:JSON 模式 + 容错解析的组合对所有 OpenAI 兼容后端通用(DashScope、DeepSeek、本地 vLLM 都支持),Function Calling 的兼容面反而参差。
- 解析容错做在客户端库而非调用方:三个业务(切片、后处理、chat)共用一套防御,调一次修三处。
- 600 秒超时:思考模式 + 长上下文(16000 rune 窗口)的真实耗时在分钟级,超时过短会把合法推理掐死。
代码示例
容错解析的三段式(索引场景):
// internal/pkg/llm/parse_indices.go(节选)
// ParseIndices 从 LLM 文本中解析句段索引数组。
// 兼容:纯 JSON 数组、markdown 代码块包裹、前后夹杂说明文字。
func ParseIndices(content string) ([]int, error) {
content = strings.TrimSpace(content)
if content == "" {
return nil, fmt.Errorf("LLM 返回内容为空")
}
// 去掉常见 markdown 代码块包裹。
if strings.HasPrefix(content, "```") {
content = strings.TrimPrefix(content, "```json")
content = strings.TrimPrefix(content, "```JSON")
content = strings.TrimPrefix(content, "```")
if idx := strings.LastIndex(content, "```"); idx >= 0 {
content = content[:idx]
}
content = strings.TrimSpace(content)
}
if indices, ok := tryUnmarshalIndices(content); ok {
return indices, nil
}
// 兜底:截取首个 JSON 数组再解析。
if extracted := extractJSONArray(content); extracted != "" && extracted != content {
if indices, ok := tryUnmarshalIndices(extracted); ok {
return indices, nil
}
}
return nil, fmt.Errorf("无法解析 LLM 返回的索引 JSON: %s", truncate(content, 256))
}
状态机扫描器(从噪声文本中捞出完整数组):
// extractJSONArray 从文本中截取第一个完整 JSON 数组。
func extractJSONArray(s string) string {
start := strings.Index(s, "[")
if start < 0 {
return ""
}
depth, inString, escape := 0, false, false
for i := start; i < len(s); i++ {
ch := s[i]
if inString {
if escape {
escape = false; continue }
if ch == '\\' {
escape = true; continue }
if ch == '"' {
inString = false }
continue
}
switch ch {
case '"': inString = true
case '[': depth++
// ']' depth-- 归零即返回(见原文件)
}
}
return ""
}
小结
- 客户端三形态:结构化(json_object+关思考)、思考模式、普通对话——意图即方法。
- 解析三段式:剥代码块 → 直接 Unmarshal → 状态机扫描兜底;错误带 256 字符现场。
- 兼容性优先选 JSON 模式而非 Function Calling,覆盖所有 OpenAI 兼容后端。
思考题
- 若模型返回的 JSON 数组里有重复索引([2,2,5]),应该在解析器、业务层还是校验层去重?各自理由。
- enable_thinking 的输出(推理过程)会占 Content 吗?结构化模式关闭思考与输出纯净度的关系如何验证?
项目信息
- GitHub仓库:Chyona/live-mixer