Files
ds2api/docs/prompt-compatibility.md

23 KiB
Raw Blame History

API -> 网页对话纯文本兼容主链路说明

文档导航:总览 / 架构说明 / 接口文档 / 测试指南

本文档是 DS2API“把 OpenAI / Claude / Gemini 风格 API 请求兼容成 DeepSeek 网页对话纯文本上下文”的专项说明。 这是项目最重要的兼容产物之一。凡是修改消息标准化、tool prompt 注入、tool history 保留、文件引用、current input file / legacy history_split、下游 completion payload 组装等行为,都必须同步更新本文档。

1. 核心结论

DS2API 当前的核心思路,不是把客户端传来的 messagestoolsattachments 原样转发给下游。

而是把这些高层 API 语义,统一压缩成 DeepSeek 网页对话更容易理解的三类输入:

  1. prompt 一个单字符串里面带有角色标记、system 指令、历史消息、assistant reasoning 标签、历史 tool call XML 等。
  2. ref_file_ids 一个文件引用数组承载附件、inline 上传文件,以及必要时被拆出去的历史文件。
  3. 控制位 例如 thinking_enabledsearch_enabled、部分 passthrough 参数。

也就是说,项目最重要的兼容动作,是把“结构化 API 会话”翻译成“网页对话纯文本上下文 + 文件引用”。

2. 为什么这是核心产物

因为对下游来说,真正稳定的输入面不是 OpenAI/Claude/Gemini 的原生 schema而是

  • 一段连续的对话 prompt
  • 一组可引用文件
  • 少量开关位

这也是为什么很多表面上看像“协议兼容”的代码,最终都会收敛到同一类逻辑:

  • 先把不同协议的消息统一成内部消息序列
  • 再把工具声明改写成 system prompt 文本
  • 再把历史 tool call / tool result 改写成 prompt 可见内容
  • 最后输出成 DeepSeek completion payload

3. 统一心智模型

当前主链路可以这样理解:

客户端请求
  -> HTTP API surfaceOpenAI / Claude / Gemini
  -> promptcompat 统一消息标准化
  -> tool prompt 注入
  -> DeepSeek 风格 prompt 拼装
  -> 文件收集 / inline 上传 / current input fileOpenAI 链路)
  -> completion payload
  -> 下游网页对话接口

对应的关键代码入口:

4. 下游真正收到的东西

在“完成标准化后”,下游 completion payload 的核心形态是:

{
  "chat_session_id": "session-id",
  "model_type": "default",
  "parent_message_id": null,
  "prompt": "<begin▁of▁sentence>...",
  "ref_file_ids": [
    "file-history",
    "file-systemprompt",
    "file-other-attachment"
  ],
  "thinking_enabled": true,
  "search_enabled": false
}

重点是:

  • prompt 才是对话上下文主载体。

  • ref_file_ids 只承载文件引用,不承载普通文本消息。

  • tools 不会作为“原生工具 schema”直接下发给下游而是被改写进 prompt

  • 当前 /v1/chat/completions 业务路径仍是“每次请求新建一个远端 chat_session_id,并默认发送 parent_message_id: null”;因此 DS2API 对外默认表现为“新会话 + prompt 拼历史”,而不是复用 DeepSeek 原生会话树。

  • 但 DeepSeek 远端本身支持同一 chat_session_id 的跨轮次持续对话。2026-04-27 已用项目内现有 DeepSeek client 做过一次不改业务代码的双轮实测:同一 chat_session_id 下,第 1 轮返回 request_message_id=1 / response_message_id=2 / 文本 SESSION_TEST_ONE;第 2 轮重新获取一次 PoW并发送 parent_message_id=2 后,成功返回 request_message_id=3 / response_message_id=4 / 文本 SESSION_TEST_TWO。这说明“同远端会话持续聊天”能力存在,且每轮需要携带正确的 parent/message 链接信息,同时重新获取对应轮次可用的 PoW。

  • OpenAI Chat / Responses 原生走统一 OpenAI 标准化与 DeepSeek payload 组装Claude / Gemini 会尽量复用 OpenAI prompt/tool 语义,其中 Gemini 直接复用 promptcompat.BuildOpenAIPromptForAdapterClaude 消息接口在可代理场景会转换为 OpenAI chat 形态再执行。

  • 客户端传入的 thinking / reasoning 开关会被归一到下游 thinking_enabled。Gemini generationConfig.thinkingConfig.thinkingBudget 会翻译成同一套 thinking 开关;关闭时即使上游返回 response/thinking_content,兼容层也不会把它当作可见正文输出。若最终解析出的模型名带 -nothinking 后缀,则会无条件强制关闭 thinking优先级高于请求体中的 thinking / reasoning / reasoning_effort。Claude surface 在流式请求且未显式声明 thinking 时,仍按 Anthropic 语义默认关闭;但在非流式代理场景,兼容层会内部开启一次下游 thinking用于捕获“正文为空、工具调用落在 thinking 里”的情况,随后在回包前剥离用户不可见的 thinking block。

  • 对 OpenAI Chat / Responses 的非流式收尾,如果最终可见正文为空,兼容层会优先尝试把思维链中的独立 DSML / XML 工具块当作真实工具调用解析出来。流式链路也会在收尾阶段做同样的 fallback 检测,但不会因为思维链内容去中途拦截或改写流式输出;真正的工具识别始终基于原始上游文本,而不是基于“已经做过可见输出清洗”的版本,因此即使最终可见层会剥离完整 leaked DSML / XML tool_calls wrapper、并抑制全空参数或无效 wrapper 块,也不会影响真实工具调用转成结构化 tool_calls / function_call。补发结果会作为本轮 assistant 的结构化 tool_calls / function_call 输出返回,而不是塞进 content 文本;如果客户端没有开启 thinking / reasoning思维链只用于检测不会作为 reasoning_content 或可见正文暴露。只有正文为空且思维链里也没有可执行工具调用时,才继续按空回复错误处理。

  • OpenAI Chat / Responses 的空回复错误处理之前会默认做一次内部补偿重试:第一次上游完整结束后,如果最终可见正文为空、没有解析到工具调用、也没有已经向客户端流式发出工具调用,并且终止原因不是 content_filter,兼容层会复用同一个 chat_session_id、账号、token 与工具策略,把原始 completion prompt 追加固定后缀 Previous reply had no visible output. Please regenerate the visible final answer or tool call now. 后重新提交一次。重试遵循 DeepSeek 多轮对话协议:从第一次上游 SSE 流中提取 response_message_id,并在重试 payload 中设置 parent_message_id 为该值,使重试成为同一会话的后续轮次而非断裂的根消息;同时重新获取一次 PoW若 PoW 获取失败则回退到原始 PoW。该重试不会重新标准化消息、不会新建 session、不会切换账号也不会向流式客户端插入重试标记第二次 thinking / reasoning 会按正常增量直接接到第一次之后,并继续使用 overlap trim 去重。若第二次仍为空,终端错误码仍保持现有 upstream_empty_output;若任一尝试触发空 content_filter,不做补偿重试并保持 content_filter 错误。JS Vercel 运行时同样设置 parent_message_id,但因无法直接调用 PoW API 而复用原始 PoW。

  • OpenAI Chat / Responses 在最终可见正文渲染阶段,会把 DeepSeek 搜索返回中的 [citation:N] / [reference:N] 标记替换成对应 Markdown 链接。citation 标记按一基序号解析;reference 标记只有在同一段正文中出现 [reference:0](允许冒号后有空格)时才按零基序号映射,并且不会影响同段正文里的 citation 标记。

5. prompt 是怎么拼出来的

OpenAI Chat / Responses 在标准化后、current input file 之前,会默认执行 thinking_injection 增强。它参考 DeepSeek V4 “把控制指令放在 user 消息末尾更稳定”的用法,在最新 user message 后追加思考增强提示词。当前内置默认提示词以 Reasoning Effort: Absolute maximum with no shortcuts permitted. 开头,并继续要求模型充分分解问题、覆盖潜在路径与边界条件、把完整推演过程显式写出。该开关默认启用,可通过 thinking_injection.enabled=false 关闭;也可以通过 thinking_injection.prompt 自定义提示词,留空时使用内置默认提示词。

这段增强属于 prompt 可见上下文:

  • 普通请求会直接出现在最终 prompt 的最新 user block 末尾。
  • 如果触发 current input file它会进入完整上下文文件中。

5.1 角色标记

最终 prompt 使用 DeepSeek 风格角色标记:

  • <begin▁of▁sentence>
  • <System>
  • <User>
  • <Assistant>
  • <Tool>
  • <end▁of▁instructions>
  • <end▁of▁sentence>
  • <end▁of▁toolresults>

实现位置: internal/prompt/messages.go

5.2 相邻同角色消息会合并

在最终 MessagesPrepareWithThinking 中,相邻同 role 的消息会被合并成一个块,中间插入空行。

这意味着:

  • prompt 中看到的是“合并后的 role block”
  • 不是客户端传来的逐条 message 原样排列

6. tools 为什么是“文本注入”,不是原生下发

当前项目把工具能力视为“prompt 约束的一部分”。

具体做法:

  1. 把每个 tool 的名称、描述、参数 schema 序列化成文本。
  2. 拼成 You have access to these tools: 大段说明。
  3. 再附上统一的 DSML tool call 外壳格式约束。
  4. 把这整段内容并入 system prompt。

工具调用正例现在优先示范官方 DSML 风格:<|DSML|tool_calls><|DSML|invoke name="..."><|DSML|parameter name="...">。 兼容层仍接受旧式纯 <tool_calls> wrapper但提示词会优先要求模型输出官方 DSML 标签,并强调不能只输出 closing wrapper 而漏掉 opening tag。需要注意这是“兼容 DSML 外壳,内部仍以 XML 解析语义为准”,不是原生 DSML 全链路实现DSML 标签会在解析入口归一化回现有 XML 标签后继续走同一套 parser。 数组参数使用 <item>...</item> 子节点表示;当某个参数体只包含 item 子节点时Go / Node 解析器会把它还原成数组,避免 questions / options 这类 schema 中要求 array 的参数被误解析成 { "item": ... } 对象。若模型把完整结构化 XML fragment 误包进 CDATA兼容层会在保护 content / command 等原文字段的前提下,尝试把非原文字段中的 CDATA XML fragment 还原成 object / array。不过如果 CDATA 只是单个平面的 XML/HTML 标签,例如 <b>urgent</b> 这种行内标记,兼容层会保留原始字符串,不会强行升成 object / array只有明显表示结构的 CDATA 片段,例如多兄弟节点、嵌套子节点或 item 列表,才会触发结构化恢复。 在 assistant 最终回包阶段,如果某个 tool 参数在声明 schema 中明确是 string,兼容层会在把解析后的 tool_calls / function_call 重新序列化成 OpenAI / Responses / Claude 可见参数前,递归把该路径上的 number / bool / object / array 统一转成字符串;其中 object / array 会压成紧凑 JSON 字符串。这个保护只对 schema 明确声明为 string 的路径生效,不会改写本来就是 number / boolean / object / array 的参数。这样可以兼容 DeepSeek 输出了结构化片段、但上游客户端工具 schema 又严格要求字符串参数的场景(例如 contentpromptpathtaskId 等)。 工具 schema 的权威来源始终是当前请求实际携带的 schema,而不是同名工具在其他 runtimeClaude Code / OpenCode / Codex 等)里的默认印象。兼容层现在会同时兼容 OpenAI 风格 function.parameters、直接工具对象上的 parameters / input_schema、以及 camelCase 的 inputSchema / schema,并在最终输出阶段按这份请求内 schema 决定是保留 array/object还是仅对明确声明为 string 的路径做字符串化。该规则同样适用于 Claude 的流式收尾和 Vercel Node 流式 tool-call formatter避免不同 runtime 因 schema shape 差异而出现同名工具参数类型漂移。 正例中的工具名只会来自当前请求实际声明的工具;如果当前请求没有足够的已知工具形态,就省略对应的单工具、多工具或嵌套示例,避免把不可用工具名写进 prompt。 对执行类工具,脚本内容必须进入执行参数本身:Bash / execute_command 使用 commandexec_command 使用 cmd;不要把脚本示范成 path / content 文件写入参数。

OpenAI 路径实现: internal/promptcompat/tool_prompt.go

Claude 路径实现: internal/httpapi/claude/handler_utils.go

统一工具调用格式模板: internal/toolcall/tool_prompt.go

这也是项目“网页对话纯文本兼容”的关键设计:

  • tools 对下游来说,本质上是 prompt 内规则
  • 不是 native tool schema transport

7. assistant 的 tool_calls / reasoning 如何保留

7.1 reasoning 保留方式

assistant 的 reasoning 会变成一个显式标签块:

[reasoning_content]
...
[/reasoning_content]

然后再接可见回答正文。

7.2 历史 tool_calls 保留方式

assistant 历史 tool_calls 不会保留成 OpenAI 原生 JSON而会转成 prompt 可见的 DSML 外壳:

<|DSML|tool_calls>
  <|DSML|invoke name="read_file">
    <|DSML|parameter name="path"><![CDATA[src/main.go]]></|DSML|parameter>
  </|DSML|invoke>
</|DSML|tool_calls>

解析层同时兼容旧式纯 XML 形态:<tool_calls> / <invoke> / <parameter>。两者都会先归一到现有 XML 解析语义;其他旧格式都会作为普通文本保留,不会作为可执行调用语法。 例外是 parser 会对一个非常窄的模型失误做修复:如果 assistant 输出了 <invoke ...> ... </tool_calls>(或 DSML 对应标签),但漏掉最前面的 opening wrapper解析阶段会补回 wrapper 后再尝试识别。

这件事很重要,因为它决定了:

  • 历史工具调用在 prompt 中是“可见文本历史”
  • 不是“隐藏结构化元数据”

实现位置: internal/prompt/tool_calls.go

7.3 tool result 保留方式

tool / function role 的结果会作为 <Tool>...<end▁of▁toolresults> 进入 prompt。

如果 tool content 为空,当前会补成字符串 "null",避免整个 tool turn 丢失。

8. files、附件、systemprompt 文件的实际语义

这里要明确区分两类东西:

  1. 文本型 system prompt 例如 OpenAI developer / system / Responses instructions / Claude top-level system 这类会进入 prompt
  2. 文件型 systemprompt 例如通过附件、input_file、base64、data URL 上传的文件 这类不会直接内联进 prompt,而是进入 ref_file_ids

OpenAI 文件相关实现:

结论:

  • “systemprompt 文字”在 prompt 里
  • “systemprompt 文件”通常只在 ref_file_ids

除非调用方自己把文件内容展开后再塞进 system/developer 文本,否则文件内容不会自动出现在 prompt 正文。

9. 多轮历史为什么不会一直完整内联在 prompt

兼容层现在只保留 current_input_file 这一种拆分方式;旧的 history_split 已废弃,只保留为兼容旧配置的字段,不再参与请求处理。

  • current_input_file 默认开启;它用于把“完整上下文”合并进 history.txt 上下文文件。当最新 user turn 的纯文本长度达到 current_input_file.min_chars(默认 0)时,兼容层会上传一个文件名为 history.txt 的上下文文件,并在 live prompt 中只保留一个中性的 user 消息要求模型直接回答最新请求,不再暴露文件名或要求模型读取本地文件。
  • 如果 current_input_file.enabled=false,请求会直接透传,不上传任何拆分上下文文件。
  • 旧的 history_split.enabled / history_split.trigger_after_turns 会被读取进配置对象以保持兼容,但不会触发拆分上传,也不会影响 current_input_file 的默认开启。

相关实现:

当前输入转文件启用并触发时,上传文件的真实文件名是 history.txt,文件内容是完整 messages 上下文;它仍会先用 OpenAI 消息标准化和 DeepSeek 角色标记序列化,并直接作为 history.txt 的纯文本内容上传(不再注入文件边界标签):

[uploaded filename]: history.txt
<begin▁of▁sentence><System>...<User>...<Assistant>...<Tool>...<User>...

开启后,请求的 live prompt 不再直接内联完整上下文,而是保留一个 user role 的短提示,提示模型基于已提供上下文直接回答最新请求;上传后的 file_id 会进入 ref_file_ids

10. 各协议入口的差异

10.1 OpenAI Chat / Responses

特点:

  • developer 会映射到 system
  • Responses instructions 会 prepend 为 system message
  • tools 会注入 system prompt
  • attachments / input_file / inline 文件会进入 ref_file_ids
  • current input file 主要在这条链路里生效,旧 history_split 仅作兼容字段保留

10.2 Claude Messages

特点:

  • top-level system 优先作为系统提示
  • tool_use / tool_result 会被转换成统一的 assistant/tool 历史语义
  • tools 同样会被并进 system prompt
  • 常规执行通过 internal/httpapi/claude/handler_messages.go 转到 OpenAI chat 路径,模型 alias 会先解析成 DeepSeek 原生模型
  • 当前代码里没有像 OpenAI 那样完整的 ref_file_ids 附件链路

10.3 Gemini

特点:

  • systemInstructioncontents.partsfunctionCallfunctionResponse 会先归一
  • tools 会转成 OpenAI 风格 function schema
  • prompt 构建复用 OpenAI 的 promptcompat.BuildOpenAIPromptForAdapter
  • 未识别的非文本 part 会被安全序列化进 prompt并对二进制/疑似 base64 内容做省略或截断处理

也就是说Gemini 在“最终 prompt 语义”上,尽量和 OpenAI 保持一致。

11. 一份贴近真实的最终上下文示意

假设用户发来一个多轮请求:

  • 有 system/developer 文本
  • 有 tools
  • 有一个文件型 systemprompt 附件
  • 有历史 assistant tool call / tool result
  • current input file 已触发

那么最终上下文更接近:

{
  "prompt": "<begin▁of▁sentence><System>原 system / developer\n\nYou have access to these tools: ...<end▁of▁instructions><User>The current request and prior conversation context have already been provided. Answer the latest user request directly.<Assistant>",
  "ref_file_ids": [
    "file-current-input-ignore",
    "file-systemprompt",
    "file-other-attachment"
  ],
  "thinking_enabled": true,
  "search_enabled": false
}

这正是“API 转网页对话纯文本”的核心成果:

  • 大部分结构化语义被压进 prompt
  • 文件保持文件
  • 需要时把完整上下文拆进 history.txt 上下文文件

12. 修改时必须同步本文档的场景

只要触碰以下任一类行为,就必须在同一提交或同一 PR 中更新本文档:

  • 角色映射变更
  • system / developer / instructions 合并规则变更
  • assistant reasoning 保留格式变更
  • assistant 历史 tool_calls 的 XML 呈现方式变更
  • tool result 注入方式变更
  • tool prompt 模板或 tool_choice 约束变更
  • inline 文件上传 / 文件引用收集规则变更
  • current input file 触发条件、上传格式、history.txt 包装格式变更
  • history_split 兼容逻辑的读取、忽略或退化行为变更
  • completion payload 字段语义变更
  • Claude / Gemini 对这套统一语义的复用关系变更

优先检查这些文件:

  • internal/promptcompat/request_normalize.go
  • internal/promptcompat/prompt_build.go
  • internal/promptcompat/message_normalize.go
  • internal/promptcompat/tool_prompt.go
  • internal/httpapi/openai/files/file_inline_upload.go
  • internal/promptcompat/file_refs.go
  • internal/httpapi/openai/history/history_split.go
  • internal/promptcompat/responses_input_normalize.go
  • internal/httpapi/claude/standard_request.go
  • internal/httpapi/claude/handler_utils.go
  • internal/httpapi/gemini/convert_request.go
  • internal/httpapi/gemini/convert_messages.go
  • internal/httpapi/gemini/convert_tools.go
  • internal/prompt/messages.go
  • internal/prompt/tool_calls.go
  • internal/promptcompat/standard_request.go

13. 建议的最小验证

改动这条链路后,至少补齐或检查这些测试:

  • go test ./internal/prompt/...
  • go test ./internal/httpapi/openai/...
  • go test ./internal/httpapi/claude/...
  • go test ./internal/httpapi/gemini/...
  • go test ./internal/util/...

如果改的是 tool call 相关兼容语义,还应同时检查:

  • go test ./internal/toolcall/...
  • node --test tests/node/stream-tool-sieve.test.js

14. 文档同步约定

本文档是这条兼容链路的专项说明。

如果外部接口行为也变了,还应同步检查:

原则是:

  • 内部主链路变化,至少更新本文档
  • 外部可见契约变化,再同步更新 API 文档