15 KiB
API -> 网页对话纯文本兼容主链路说明
本文档是 DS2API“把 OpenAI / Claude / Gemini 风格 API 请求兼容成 DeepSeek 网页对话纯文本上下文”的专项说明。 这是项目最重要的兼容产物之一。凡是修改消息标准化、tool prompt 注入、tool history 保留、文件引用、history split、下游 completion payload 组装等行为,都必须同步更新本文档。
1. 核心结论
DS2API 当前的核心思路,不是把客户端传来的 messages、tools、attachments 原样转发给下游。
而是把这些高层 API 语义,统一压缩成 DeepSeek 网页对话更容易理解的三类输入:
prompt一个单字符串,里面带有角色标记、system 指令、历史消息、assistant reasoning 标签、历史 tool call XML 等。ref_file_ids一个文件引用数组,承载附件、inline 上传文件,以及必要时被拆出去的历史文件。- 控制位
例如
thinking_enabled、search_enabled、部分 passthrough 参数。
也就是说,项目最重要的兼容动作,是把“结构化 API 会话”翻译成“网页对话纯文本上下文 + 文件引用”。
2. 为什么这是核心产物
因为对下游来说,真正稳定的输入面不是 OpenAI/Claude/Gemini 的原生 schema,而是:
- 一段连续的对话 prompt
- 一组可引用文件
- 少量开关位
这也是为什么很多表面上看像“协议兼容”的代码,最终都会收敛到同一类逻辑:
- 先把不同协议的消息统一成内部消息序列
- 再把工具声明改写成 system prompt 文本
- 再把历史 tool call / tool result 改写成 prompt 可见内容
- 最后输出成 DeepSeek completion payload
3. 统一心智模型
当前主链路可以这样理解:
客户端请求
-> HTTP API surface(OpenAI / Claude / Gemini)
-> promptcompat 统一消息标准化
-> tool prompt 注入
-> DeepSeek 风格 prompt 拼装
-> 文件收集 / inline 上传 / history split(OpenAI 链路)
-> completion payload
-> 下游网页对话接口
对应的关键代码入口:
- OpenAI Chat / Responses: internal/promptcompat/request_normalize.go
- OpenAI prompt 组装: internal/promptcompat/prompt_build.go
- OpenAI 消息标准化: internal/promptcompat/message_normalize.go
- Claude 标准化: internal/httpapi/claude/standard_request.go
- Claude 消息与 tool_use/tool_result 归一: internal/httpapi/claude/handler_utils.go
- Gemini 复用 OpenAI prompt builder: internal/httpapi/gemini/convert_request.go
- DeepSeek prompt 角色标记拼装: internal/prompt/messages.go
- prompt 可见 tool history XML: internal/prompt/tool_calls.go
- completion payload: internal/promptcompat/standard_request.go
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。- OpenAI Chat / Responses 原生走统一 OpenAI 标准化与 DeepSeek payload 组装;Claude / Gemini 会尽量复用 OpenAI prompt/tool 语义,其中 Gemini 直接复用
promptcompat.BuildOpenAIPromptForAdapter,Claude 消息接口在可代理场景会转换为 OpenAI chat 形态再执行。 - 客户端传入的 thinking / reasoning 开关会被归一到下游
thinking_enabled。Claude surface 没有thinking字段时按 Anthropic 语义视为关闭;GeminigenerationConfig.thinkingConfig.thinkingBudget会翻译成同一套 thinking 开关;关闭时即使上游返回response/thinking_content,兼容层也不会把它当作可见正文输出。
5. prompt 是怎么拼出来的
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 thinking continuity 说明
如果启用了 thinking,会在最前面额外插入一个 system block,提醒模型:
- 继续既有会话,不要重开
- earlier messages 是 binding context
- 不要把最终回答只留在 reasoning 里
这部分不是客户端原始消息,而是兼容层主动补进去的连续性契约。
5.3 相邻同角色消息会合并
在最终 MessagesPrepareWithThinking 中,相邻同 role 的消息会被合并成一个块,中间插入空行。
这意味着:
- prompt 中看到的是“合并后的 role block”
- 不是客户端传来的逐条 message 原样排列
6. tools 为什么是“文本注入”,不是原生下发
当前项目把工具能力视为“prompt 约束的一部分”。
具体做法:
- 把每个 tool 的名称、描述、参数 schema 序列化成文本。
- 拼成
You have access to these tools:大段说明。 - 再附上统一的 XML tool call 格式约束。
- 把这整段内容并入 system prompt。
工具调用正例仍只示范 canonical XML:<tool_calls> → <invoke name="..."> → <parameter name="...">。
提示词会额外强调:如果要调用工具,工具块的首个非空白字符必须就是 <tool_calls>,不能只输出 </tool_calls> 而漏掉 opening tag。
正例中的工具名只会来自当前请求实际声明的工具;如果当前请求没有足够的已知工具形态,就省略对应的单工具、多工具或嵌套示例,避免把不可用工具名写进 prompt。
对执行类工具,脚本内容必须进入执行参数本身:Bash / execute_command 使用 command,exec_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 可见的 XML:
<tool_calls>
<invoke name="read_file">
<parameter name="path"><![CDATA[src/main.go]]></parameter>
</invoke>
</tool_calls>
这也是当前项目里唯一受支持的 canonical tool-calling 形态;其他形态都会作为普通文本保留,不会作为可执行调用语法。
例外是 parser 会对一个非常窄的模型失误做修复:如果 assistant 输出了 <invoke ...> ... </tool_calls>,但漏掉最前面的 opening <tool_calls>,解析阶段会补回 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 文件的实际语义
这里要明确区分两类东西:
- 文本型 system prompt
例如 OpenAI
developer/system/ Responsesinstructions/ Claude top-levelsystem这类会进入prompt。 - 文件型 systemprompt
例如通过附件、
input_file、base64、data URL 上传的文件 这类不会直接内联进prompt,而是进入ref_file_ids。
OpenAI 文件相关实现:
- inline/base64/data URL 上传: internal/httpapi/openai/files/file_inline_upload.go
- 文件 ID 收集: internal/promptcompat/file_refs.go
结论:
- “systemprompt 文字”在 prompt 里
- “systemprompt 文件”通常只在
ref_file_ids里
除非调用方自己把文件内容展开后再塞进 system/developer 文本,否则文件内容不会自动出现在 prompt 正文。
9. 多轮历史为什么不会一直完整内联在 prompt
history split 现在全局强制开启;旧配置中的 history_split.enabled=false 会被忽略。默认从第 2 个 user turn 起就可能触发,仍可通过 history_split.trigger_after_turns 调整触发阈值。
相关实现:
触发后行为:
- 旧历史消息被切出去。
- 旧历史会被重新序列化成一个文本文件。
- 真正上传的文件名固定是
HISTORY.txt。 - 文件内容内部会使用
IGNORE这层包装名来闭合 DeepSeek 官网原生文件标记。 - 该文件上传后,其
file_id会排在ref_file_ids最前面。 - live prompt 只保留:
- system / developer
- 最新 user turn 起的上下文
历史文件内容不是普通自由文本,而是用同一套角色标记再次序列化出的 transcript:
[uploaded filename]: HISTORY.txt
[file content end]
<|begin▁of▁sentence|><|User|>...<|Assistant|>...<|Tool|>...
[file name]: IGNORE
[file content begin]
所以“完整上下文”在当前实现里,其实通常分散在两处:
prompt里的 live contextref_file_ids指向的 history transcript file
10. 各协议入口的差异
10.1 OpenAI Chat / Responses
特点:
developer会映射到system- Responses
instructions会 prepend 为 system message tools会注入 system promptattachments/input_file/ inline 文件会进入ref_file_ids- 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
特点:
systemInstruction、contents.parts、functionCall、functionResponse会先归一- tools 会转成 OpenAI 风格 function schema
- prompt 构建复用 OpenAI 的
promptcompat.BuildOpenAIPromptForAdapter - 未识别的非文本 part 会被安全序列化进 prompt,并对二进制/疑似 base64 内容做省略或截断处理
也就是说,Gemini 在“最终 prompt 语义”上,尽量和 OpenAI 保持一致。
11. 一份贴近真实的最终上下文示意
假设用户发来一个多轮请求:
- 有 system/developer 文本
- 有 tools
- 有一个文件型 systemprompt 附件
- 有历史 assistant tool call / tool result
- history split 已触发
那么最终上下文更接近:
{
"prompt": "<|begin▁of▁sentence|><|System|>continuity instructions...\\n\\n原 system / developer\\n\\nYou have access to these tools: ...<|end▁of▁instructions|><|User|>最新问题<|Assistant|>",
"ref_file_ids": [
"file-history-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 文件上传 / 文件引用收集规则变更
- history split 触发条件、上传格式、
IGNORE包装格式变更 - completion payload 字段语义变更
- Claude / Gemini 对这套统一语义的复用关系变更
优先检查这些文件:
internal/promptcompat/request_normalize.gointernal/promptcompat/prompt_build.gointernal/promptcompat/message_normalize.gointernal/promptcompat/tool_prompt.gointernal/httpapi/openai/files/file_inline_upload.gointernal/promptcompat/file_refs.gointernal/httpapi/openai/history/history_split.gointernal/promptcompat/responses_input_normalize.gointernal/httpapi/claude/standard_request.gointernal/httpapi/claude/handler_utils.gointernal/httpapi/gemini/convert_request.gointernal/httpapi/gemini/convert_messages.gointernal/httpapi/gemini/convert_tools.gointernal/prompt/messages.gointernal/prompt/tool_calls.gointernal/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 文档