DS2API assistantturn输出语义层:四种协议如何统一到同一turn模型
【免费下载链接】ds2apiDeepSeek-Compatible Middleware Interface: A technical exploration project in Go, focusing on high-concurrency protocol adaptation. It serves as a reference implementation for converting diverse web protocols into standardized formats.项目地址: https://gitcode.com/GitHub_Trending/ds/ds2api
DS2API 是一个把 DeepSeek 网页协议转换为 OpenAI、Claude、Gemini 等标准接口的 Go 语言中间件。它的internal/assistantturn输出语义层解决了一个核心问题:四种对外协议、流式与非流式两种输出模式,如何收敛到同一套 assistant turn 模型上。本文将带你快速看懂这套设计。
一、问题:4 种协议 × 2 种模式 = 8 份重复代码?
DS2API 对外提供四套 API:
| 协议 | 路由入口 | 典型形态 |
|---|---|---|
| OpenAI Chat Completions | /v1/chat/completions | choices + finish_reason |
| OpenAI Responses | /v1/responses | response 对象 + usage |
| Claude Messages | /v1/messages | content blocks + stop_reason |
| Gemini generateContent | /v1beta/.../generateContent | candidates + parts |
每种协议又有非流式(一次性返回 JSON)和流式(SSE 增量推送)两种输出。如果各写各的收尾逻辑,"工具调用识别、思考内容清洗、引用链接替换、token 用量统计、空输出判定"这些语义就要在 8 处重复实现,且极易不一致。
DS2API 的做法是:在 docs/ARCHITECTURE.md 描述的请求主链路中,于"流式消费引擎"与"协议格式化层"之间插入一个输出语义层——internal/assistantturn。上游不管来自哪条路径,最终都归一为一个Turn,再由各协议渲染器翻译回各自的 JSON 结构。
二、turn 模型:一次 assistant 回复的完整快照
核心结构定义在 turn.go:
type Turn struct { Text string // 清洗后的正文 Thinking string // 清洗后的思考内容 ToolCalls []toolcall.ParsedToolCall // 解析出的工具调用 CitationLinks map[int]string // 引用链接 ContentFilter bool // 是否被内容过滤 StopReason StopReason // stop / tool_calls / content_filter / error Usage Usage // 输入/输出/推理/总 token Error *OutputError // 输出侧校验错误 }它同时保留了RawText/RawThinking原始片段,供 responsehistory 在协议回译前归档原始输出。
值得注意的语义约定:
- 停止原因优先级:内容过滤 > 工具调用 > 正常结束,见 turn.go#L104-L110
- 空输出判定:无工具调用、无正文时,区分"限流只返回思考"(429)、"内容过滤"(400)、"上游不可用"(503)三种错误,见 UpstreamEmptyOutputDetail
- 空输出重试:ShouldRetryEmptyOutput 供 empty_retry_runtime.go 判断是否换账号重试
三、两个入口:非流式收集 vs 流式快照
语义层只有两个构建入口,分别对应两种输出模式:
| 入口 | 适用模式 | 输入来源 |
|---|---|---|
| BuildTurnFromCollected | 非流式 | sse.CollectResult(完整收集结果) |
| BuildTurnFromStreamSnapshot | 流式收尾 | StreamSnapshot(流式累积快照) |
流式路径中,累积器 Accumulator 逐块消费 internal/sse 的解析结果,维护正文、思考、工具调用检测等多条缓冲区;流结束时一次性Snapshot(),交给BuildTurnFromStreamSnapshot。
流式入口还处理了两个协议特有的状态位:AlreadyEmittedCalls/AlreadyEmittedToolRaw——如果工具调用在流式过程中已经推给客户端,收尾时不能再报tool_choice冲突错误。这种"过程中已发生的事实"被显式建模,避免了收尾逻辑与推送逻辑打架。
四、四种协议如何共用同一个 turn
四个协议的处理器在收尾阶段都遵循同一套三步曲:构建 Turn → FinalizeTurn → 渲染响应。
1️⃣ OpenAI Chat Completions
handler_chat.go#L169-L190 中构建 Turn 后,用 OpenAIChatUsage 生成prompt_tokens / completion_tokens及reasoning_tokens明细,finish_reason直接取 FinishReason(stop/tool_calls/content_filter),与 OpenAI 官方枚举一一对应。
2️⃣ OpenAI Responses
responses_handler.go#L153-L170 使用同一 Turn,仅用量字段不同——OpenAIResponsesUsage 输出input_tokens / output_tokens,对应 Responses API 的字段命名。工具调用流式细节见 responses_stream_runtime_toolcalls.go。
3️⃣ Claude Messages
Claude 的"块状内容"模型由 format/claude/render.go 直接从 Turn 渲染:Turn.Thinking→thinking块,Turn.ToolCalls→tool_use块(并生成toolu_前缀 ID),StopReason映射为 Claude 的end_turn/tool_use。流式收尾见 stream_runtime_finalize.go#L117-L135。
4️⃣ Gemini generateContent
handler_generate.go 的buildGeminiPartsFromTurn把Turn.Text翻译成text类型的Part,工具调用翻译成对应结构;流式路径通过 handler_stream_runtime.go#L306-L322 在收尾时构建 Turn 并取FinishReason填充finishReason字段。
💡 关键设计:渲染器只读 Turn,不做语义判断。工具调用解析、内容清洗、引用替换全部前置到语义层,四个渲染器变成纯粹的"结构翻译器"。
五、FinalizeTurn:统一出口判定
FinalizeTurn 在 Turn 之上做最后一次裁决,产出FinalOutcome:
FinishReason:各协议的结束原因(有工具调用时强制为tool_calls)ShouldFail+Error:是否输出侧校验失败(如tool_choice要求调用工具但未调用,见 ValidateTurn)HasVisibleOutput:正文 / 思考 / 工具调用三者是否有任意可见输出,供各协议决定"是否需要兜底文案或报错"
这一步保证了:无论客户端请求的是 Chat、Responses、Claude 还是 Gemini,"这次输出算成功还是失败"的判定标准完全一致——这是多协议网关最容易失守的语义一致性环节。
六、这套分层设计值得借鉴的点 🌟
- 语义与结构解耦:
Turn只关心"模型说了什么、为什么停",各协议 JSON 结构只是它的视图。新增第五种协议时,只需写一个渲染器,不用重写语义。 - 流式/非流式同构:两个构建入口输出同一个
Turn类型,下游FinalizeTurn与渲染器完全复用,流式路径不再需要一套"平行的收尾代码"。 - 错误语义前置:内容过滤、限流、上游不可用的区分在语义层完成,各协议只需映射自己的状态码与文案。
- 状态显式化:
AlreadyEmittedToolCalls这类"推送已发生"的事实进入 Turn 构建参数,消除流式收尾的竞态歧义。
七、延伸阅读与模块索引 📚
| 模块 | 路径 | 职责 |
|---|---|---|
| 输出语义层 | internal/assistantturn/ | Turn 模型与流事件定义 |
| 统一测试 | turn_test.go | 语义层行为回归 |
| 流式引擎 | internal/stream/ | 统一流式消费 |
| SSE 解析 | internal/sse/ | 上游增量解析 |
| 工具调用解析 | internal/toolcall/ | DSML/XML 工具调用归一 |
| 非流式运行时 | internal/completionruntime/nonstream.go | 收集 + 空输出重试 |
| 工具调用语义专题 | docs/toolcall-semantics.md | 工具调用行为说明 |
理解完这套 turn 模型后,再阅读各协议渲染器会非常轻松——它们的差异全部是"翻译差异",而不是"逻辑差异"。这正是 DS2API 能同时稳定维护四种协议输出一致性的根本原因。
【免费下载链接】ds2apiDeepSeek-Compatible Middleware Interface: A technical exploration project in Go, focusing on high-concurrency protocol adaptation. It serves as a reference implementation for converting diverse web protocols into standardized formats.项目地址: https://gitcode.com/GitHub_Trending/ds/ds2api
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考