Files
ds2api/docs/prompt-compatibility.md
2026-04-27 02:09:11 +08:00

425 lines
19 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# API -> 网页对话纯文本兼容主链路说明
文档导航:[总览](../README.MD) / [架构说明](./ARCHITECTURE.md) / [接口文档](../API.md) / [测试指南](./TESTING.md)
> 本文档是 DS2API“把 OpenAI / Claude / Gemini 风格 API 请求兼容成 DeepSeek 网页对话纯文本上下文”的专项说明。
> 这是项目最重要的兼容产物之一。凡是修改消息标准化、tool prompt 注入、tool history 保留、文件引用、history split、下游 completion payload 组装等行为,都必须同步更新本文档。
## 1. 核心结论
DS2API 当前的核心思路,不是把客户端传来的 `messages``tools``attachments` 原样转发给下游。
而是把这些高层 API 语义,统一压缩成 DeepSeek 网页对话更容易理解的三类输入:
1. `prompt`
一个单字符串里面带有角色标记、system 指令、历史消息、assistant reasoning 标签、历史 tool call XML 等。
2. `ref_file_ids`
一个文件引用数组承载附件、inline 上传文件,以及必要时被拆出去的历史文件。
3. 控制位
例如 `thinking_enabled``search_enabled`、部分 passthrough 参数。
也就是说,项目最重要的兼容动作,是把“结构化 API 会话”翻译成“网页对话纯文本上下文 + 文件引用”。
## 2. 为什么这是核心产物
因为对下游来说,真正稳定的输入面不是 OpenAI/Claude/Gemini 的原生 schema而是
- 一段连续的对话 prompt
- 一组可引用文件
- 少量开关位
这也是为什么很多表面上看像“协议兼容”的代码,最终都会收敛到同一类逻辑:
- 先把不同协议的消息统一成内部消息序列
- 再把工具声明改写成 system prompt 文本
- 再把历史 tool call / tool result 改写成 prompt 可见内容
- 最后输出成 DeepSeek completion payload
## 3. 统一心智模型
当前主链路可以这样理解:
```text
客户端请求
-> HTTP API surfaceOpenAI / Claude / Gemini
-> promptcompat 统一消息标准化
-> tool prompt 注入
-> DeepSeek 风格 prompt 拼装
-> 文件收集 / inline 上传 / history splitOpenAI 链路)
-> completion payload
-> 下游网页对话接口
```
对应的关键代码入口:
- OpenAI Chat / Responses
[internal/promptcompat/request_normalize.go](../internal/promptcompat/request_normalize.go)
- OpenAI prompt 组装:
[internal/promptcompat/prompt_build.go](../internal/promptcompat/prompt_build.go)
- OpenAI 消息标准化:
[internal/promptcompat/message_normalize.go](../internal/promptcompat/message_normalize.go)
- Claude 标准化:
[internal/httpapi/claude/standard_request.go](../internal/httpapi/claude/standard_request.go)
- Claude 消息与 tool_use/tool_result 归一:
[internal/httpapi/claude/handler_utils.go](../internal/httpapi/claude/handler_utils.go)
- Gemini 复用 OpenAI prompt builder
[internal/httpapi/gemini/convert_request.go](../internal/httpapi/gemini/convert_request.go)
- DeepSeek prompt 角色标记拼装:
[internal/prompt/messages.go](../internal/prompt/messages.go)
- prompt 可见 tool history XML
[internal/prompt/tool_calls.go](../internal/prompt/tool_calls.go)
- 最新 user 思考格式注入:
[internal/promptcompat/thinking_injection.go](../internal/promptcompat/thinking_injection.go)
- completion payload
[internal/promptcompat/standard_request.go](../internal/promptcompat/standard_request.go)
## 4. 下游真正收到的东西
在“完成标准化后”,下游 completion payload 的核心形态是:
```json
{
"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`。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` 或可见正文暴露。只有正文为空且思维链里也没有可执行工具调用时,才继续按空回复错误处理。
## 5. prompt 是怎么拼出来的
OpenAI Chat / Responses 在标准化后、history split / 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 末尾。
- 如果触发 `HISTORY.txt`,它会保留在 live context 的最新 user turn 中。
- 如果触发 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](../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。
正例中的工具名只会来自当前请求实际声明的工具;如果当前请求没有足够的已知工具形态,就省略对应的单工具、多工具或嵌套示例,避免把不可用工具名写进 prompt。
对执行类工具,脚本内容必须进入执行参数本身:`Bash` / `execute_command` 使用 `command``exec_command` 使用 `cmd`;不要把脚本示范成 `path` / `content` 文件写入参数。
OpenAI 路径实现:
[internal/promptcompat/tool_prompt.go](../internal/promptcompat/tool_prompt.go)
Claude 路径实现:
[internal/httpapi/claude/handler_utils.go](../internal/httpapi/claude/handler_utils.go)
统一工具调用格式模板:
[internal/toolcall/tool_prompt.go](../internal/toolcall/tool_prompt.go)
这也是项目“网页对话纯文本兼容”的关键设计:
- tools 对下游来说,本质上是 prompt 内规则
- 不是 native tool schema transport
## 7. assistant 的 tool_calls / reasoning 如何保留
### 7.1 reasoning 保留方式
assistant 的 reasoning 会变成一个显式标签块:
```text
[reasoning_content]
...
[/reasoning_content]
```
然后再接可见回答正文。
### 7.2 历史 tool_calls 保留方式
assistant 历史 `tool_calls` 不会保留成 OpenAI 原生 JSON而会转成 prompt 可见的 DSML 外壳:
```xml
<|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](../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 文件相关实现:
- inline/base64/data URL 上传:
[internal/httpapi/openai/files/file_inline_upload.go](../internal/httpapi/openai/files/file_inline_upload.go)
- 文件 ID 收集:
[internal/promptcompat/file_refs.go](../internal/promptcompat/file_refs.go)
结论:
- “systemprompt 文字”在 prompt 里
- “systemprompt 文件”通常只在 `ref_file_ids`
除非调用方自己把文件内容展开后再塞进 system/developer 文本,否则文件内容不会自动出现在 prompt 正文。
## 9. 多轮历史为什么不会一直完整内联在 prompt
兼容层提供两种拆分策略:
- `history_split` 是轮次拆分,默认关闭;开启后默认从第 2 个 user turn 起触发,可通过 `history_split.trigger_after_turns` 调整阈值。
- `current_input_file` 是独立拆分,默认开启;它用于把“完整上下文”合并进隐藏上下文文件。当最新 user turn 的纯文本长度达到 `current_input_file.min_chars`(默认 `0`)时,兼容层会上传一个文件名为 `IGNORE.txt` 的上下文文件,并在 live prompt 中只保留一个中性的 user 消息要求模型直接回答最新请求,不再暴露文件名或要求模型读取本地文件。
两个策略互斥,最多只能启用一个。如果两个开关都关闭,请求会直接透传,不上传 `HISTORY.txt` 或 current input file。
相关实现:
- 配置访问器:
[internal/config/store_accessors.go](../internal/config/store_accessors.go)
- 历史拆分:
[internal/httpapi/openai/history/history_split.go](../internal/httpapi/openai/history/history_split.go)
- 当前输入转文件:
[internal/httpapi/openai/history/current_input_file.go](../internal/httpapi/openai/history/current_input_file.go)
history split 触发后行为:
1. 旧历史消息被切出去。
2. 旧历史会被重新序列化成一个文本文件。
3. 真正上传的文件名固定是 `HISTORY.txt`
4. 文件内容内部会使用 `IGNORE` 这层包装名来闭合 DeepSeek 官网原生文件标记。
5. 该文件上传后,其 `file_id` 会排在 `ref_file_ids` 最前面。
6. live prompt 只保留:
- system / developer
- 最新 user turn 起的上下文
历史文件内容不是普通自由文本,而是用同一套角色标记再次序列化出的 transcript
```text
[uploaded filename]: HISTORY.txt
[file content end]
<begin▁of▁sentence><User>...<Assistant>...<Tool>...
[file name]: IGNORE
[file content begin]
```
所以“完整上下文”在当前实现里,其实通常分散在两处:
- `prompt` 里的 live context
- `ref_file_ids` 指向的 history transcript file
当前输入转文件启用并触发时,不会同时启用 history split也不会上传 `HISTORY.txt`。上传文件的真实文件名是 `IGNORE.txt`,文件内容是完整 `messages` 上下文;它仍会先用 OpenAI 消息标准化和 DeepSeek 角色标记序列化,再包进 `IGNORE` 文件边界里:
```text
[uploaded filename]: IGNORE.txt
[file content end]
<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`
- 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 已触发
那么最终上下文更接近:
```json
{
"prompt": "<begin▁of▁sentence><System>原 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.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.md](../API.md)
- [API.en.md](../API.en.md)
- [docs/toolcall-semantics.md](./toolcall-semantics.md)
原则是:
- 内部主链路变化,至少更新本文档
- 外部可见契约变化,再同步更新 API 文档