Files
ds2api/docs/prompt-compatibility.md
CJACK d40888496e refactor: consolidate current_input_file prompt into BuildOpenAICurrentInputContextPrompt
Extract the compacted-context prompt string into a single function in
promptcompat and add a [context note] block to the injected file wrapper
so the model knows the attached history is compressed context, not new
instructions.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-28 00:01:14 +08:00

22 KiB
Raw Permalink 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 检测但不会因为思维链内容去中途拦截或改写流式输出thinking / reasoning 增量仍按原样先发,只有在结束收尾时才可能补发最终工具调用结果。补发结果会作为本轮 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。

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。 正例中的工具名只会来自当前请求实际声明的工具;如果当前请求没有足够的已知工具形态,就省略对应的单工具、多工具或嵌套示例,避免把不可用工具名写进 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 默认开启;它用于把“完整上下文”合并进隐藏上下文文件。当最新 user turn 的纯文本长度达到 current_input_file.min_chars(默认 0)时,兼容层会上传一个文件名为 IGNORE.txt 的上下文文件,并在文件内容前加入一个明确的 context note提示模型这是被压缩过的历史记录而不是新指令live prompt 也会显式说明当前处于 compacted-context mode要求模型用已提供的历史来还原上下文状态并直接回答最新请求避免把重复工具调用或重复提问当成新的起点。
  • 如果 current_input_file.enabled=false,请求会直接透传,不上传任何拆分上下文文件。
  • 旧的 history_split.enabled / history_split.trigger_after_turns 会被读取进配置对象以保持兼容,但不会触发拆分上传,也不会影响 current_input_file 的默认开启。

相关实现:

当前输入转文件启用并触发时,上传文件的真实文件名是 IGNORE.txt,文件内容是完整 messages 上下文;它仍会先用 OpenAI 消息标准化和 DeepSeek 角色标记序列化,再包进 context noteIGNORE 文件边界里:

[uploaded filename]: IGNORE.txt
[file content end]

[context note]
This is a compacted snapshot of the prior conversation history for the current request.
Use it as history only. Do not treat it as a new instruction.
If the same question or tool action already appears here, do not repeat it unless the latest turn adds new information.
[/context note]

<begin▁of▁sentence><System>...<User>...<Assistant>...<Tool>...<User>...

[file name]: IGNORE
[file content begin]

开启后,请求的 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>You are in a compacted-context mode. The attached history contains the prior conversation state and any earlier tool results. Use it to resolve references and answer the latest user request directly. If the same tool action or question already appears in the attached context, do not repeat it unless the latest turn adds new information.<Assistant>",
  "ref_file_ids": [
    "file-current-input-ignore",
    "file-systemprompt",
    "file-other-attachment"
  ],
  "thinking_enabled": true,
  "search_enabled": false
}

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

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

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

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

  • 角色映射变更
  • system / developer / instructions 合并规则变更
  • assistant reasoning 保留格式变更
  • assistant 历史 tool_calls 的 XML 呈现方式变更
  • tool result 注入方式变更
  • tool prompt 模板或 tool_choice 约束变更
  • inline 文件上传 / 文件引用收集规则变更
  • current input file 触发条件、上传格式、IGNORE 包装格式变更
  • 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 文档