mirror of
https://github.com/CJackHwang/ds2api.git
synced 2026-05-04 16:35:27 +08:00
增加不同上下文模式
This commit is contained in:
@@ -284,7 +284,7 @@ go run ./cmd/ds2api
|
||||
- `history_split`:轮次拆分策略;默认关闭,开启后默认从第二轮开始将旧历史上传为 `HISTORY.txt`。
|
||||
- `current_input_file`:独立拆分策略;默认开启且阈值为 `0`,触发时将完整上下文合并上传为隐藏上下文文件,并跳过 `HISTORY.txt`。
|
||||
- `history_split` 与 `current_input_file` 互斥,最多启用一个;两者都关闭时请求直接透传。
|
||||
- `thinking_injection`:默认开启;在最新 user 消息末尾追加思考格式增强提示,提高工具调用前的思考结构稳定性。
|
||||
- `thinking_injection`:默认开启;在最新 user 消息末尾追加思考格式增强提示,提高工具调用前的思考结构稳定性;`prompt` 留空时使用内置默认提示词。
|
||||
|
||||
环境变量完整列表见 [部署指南](docs/DEPLOY.md),接口鉴权规则见 [API.md](API.md#鉴权规则)。
|
||||
|
||||
|
||||
@@ -59,7 +59,8 @@
|
||||
"min_chars": 0
|
||||
},
|
||||
"thinking_injection": {
|
||||
"enabled": true
|
||||
"enabled": true,
|
||||
"prompt": ""
|
||||
},
|
||||
"embeddings": {
|
||||
"provider": "deterministic"
|
||||
|
||||
@@ -103,7 +103,7 @@ DS2API 当前的核心思路,不是把客户端传来的 `messages`、`tools`
|
||||
|
||||
## 5. prompt 是怎么拼出来的
|
||||
|
||||
OpenAI Chat / Responses 在标准化后、history split / current input file 之前,会默认执行 `thinking_injection` 增强。它参考 DeepSeek V4 “把控制指令放在 user 消息末尾更稳定”的用法,在最新 user message 后追加 `【思维链格式要求】...`,要求模型在 `<think>` 内按分析、构思、工具调用、XML 工具格式回顾这几个阶段组织思考。该开关默认启用,可通过 `thinking_injection.enabled=false` 关闭。
|
||||
OpenAI Chat / Responses 在标准化后、history split / current input file 之前,会默认执行 `thinking_injection` 增强。它参考 DeepSeek V4 “把控制指令放在 user 消息末尾更稳定”的用法,在最新 user message 后追加思考格式提示词,默认内容以 `【思维链格式要求】...` 开头,要求模型在 `<think>` 内按分析、构思、工具调用、XML 工具格式回顾这几个阶段组织思考。该开关默认启用,可通过 `thinking_injection.enabled=false` 关闭;也可以通过 `thinking_injection.prompt` 自定义提示词,留空时使用内置默认提示词。
|
||||
|
||||
这段增强属于 prompt 可见上下文:
|
||||
|
||||
|
||||
@@ -51,7 +51,7 @@ func (c Config) MarshalJSON() ([]byte, error) {
|
||||
if c.CurrentInputFile.Enabled != nil || c.CurrentInputFile.MinChars != 0 {
|
||||
m["current_input_file"] = c.CurrentInputFile
|
||||
}
|
||||
if c.ThinkingInjection.Enabled != nil {
|
||||
if c.ThinkingInjection.Enabled != nil || strings.TrimSpace(c.ThinkingInjection.Prompt) != "" {
|
||||
m["thinking_injection"] = c.ThinkingInjection
|
||||
}
|
||||
if c.VercelSyncHash != "" {
|
||||
@@ -177,6 +177,7 @@ func (c Config) Clone() Config {
|
||||
},
|
||||
ThinkingInjection: ThinkingInjectionConfig{
|
||||
Enabled: cloneBoolPtr(c.ThinkingInjection.Enabled),
|
||||
Prompt: c.ThinkingInjection.Prompt,
|
||||
},
|
||||
VercelSyncHash: c.VercelSyncHash,
|
||||
VercelSyncTime: c.VercelSyncTime,
|
||||
|
||||
@@ -184,5 +184,6 @@ type CurrentInputFileConfig struct {
|
||||
}
|
||||
|
||||
type ThinkingInjectionConfig struct {
|
||||
Enabled *bool `json:"enabled,omitempty"`
|
||||
Enabled *bool `json:"enabled,omitempty"`
|
||||
Prompt string `json:"prompt,omitempty"`
|
||||
}
|
||||
|
||||
@@ -208,3 +208,9 @@ func (s *Store) ThinkingInjectionEnabled() bool {
|
||||
}
|
||||
return *s.cfg.ThinkingInjection.Enabled
|
||||
}
|
||||
|
||||
func (s *Store) ThinkingInjectionPrompt() string {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return strings.TrimSpace(s.cfg.ThinkingInjection.Prompt)
|
||||
}
|
||||
|
||||
@@ -83,4 +83,9 @@ func TestStoreThinkingInjectionAccessors(t *testing.T) {
|
||||
if store.ThinkingInjectionEnabled() {
|
||||
t.Fatal("expected thinking injection disabled by explicit config")
|
||||
}
|
||||
|
||||
store.cfg.ThinkingInjection.Prompt = " custom thinking prompt "
|
||||
if got := store.ThinkingInjectionPrompt(); got != "custom thinking prompt" {
|
||||
t.Fatalf("thinking injection prompt=%q want custom thinking prompt", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,6 +75,12 @@ func TestGetSettingsIncludesHistorySplitDefaults(t *testing.T) {
|
||||
if got := boolFrom(thinkingInjection["enabled"]); !got {
|
||||
t.Fatalf("expected thinking_injection.enabled=true, body=%v", body)
|
||||
}
|
||||
if got, _ := thinkingInjection["prompt"].(string); got != "" {
|
||||
t.Fatalf("expected empty custom thinking prompt, got %q body=%v", got, body)
|
||||
}
|
||||
if got, _ := thinkingInjection["default_prompt"].(string); got == "" {
|
||||
t.Fatalf("expected default thinking prompt, body=%v", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateSettingsValidation(t *testing.T) {
|
||||
@@ -264,6 +270,7 @@ func TestUpdateSettingsThinkingInjection(t *testing.T) {
|
||||
payload := map[string]any{
|
||||
"thinking_injection": map[string]any{
|
||||
"enabled": false,
|
||||
"prompt": " custom thinking prompt ",
|
||||
},
|
||||
}
|
||||
b, _ := json.Marshal(payload)
|
||||
@@ -280,6 +287,9 @@ func TestUpdateSettingsThinkingInjection(t *testing.T) {
|
||||
if h.Store.ThinkingInjectionEnabled() {
|
||||
t.Fatal("expected thinking injection accessor to reflect disabled config")
|
||||
}
|
||||
if got := h.Store.ThinkingInjectionPrompt(); got != "custom thinking prompt" {
|
||||
t.Fatalf("expected custom thinking prompt, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateSettingsAutoDeleteMode(t *testing.T) {
|
||||
|
||||
@@ -199,6 +199,9 @@ func parseSettingsUpdateRequest(req map[string]any) (*config.AdminConfig, *confi
|
||||
b := boolFrom(v)
|
||||
cfg.Enabled = &b
|
||||
}
|
||||
if v, exists := raw["prompt"]; exists {
|
||||
cfg.Prompt = strings.TrimSpace(fmt.Sprintf("%v", v))
|
||||
}
|
||||
thinkingInjCfg = cfg
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
|
||||
authn "ds2api/internal/auth"
|
||||
"ds2api/internal/config"
|
||||
"ds2api/internal/promptcompat"
|
||||
)
|
||||
|
||||
func (h *Handler) getSettings(w http.ResponseWriter, _ *http.Request) {
|
||||
@@ -39,7 +40,9 @@ func (h *Handler) getSettings(w http.ResponseWriter, _ *http.Request) {
|
||||
"min_chars": h.Store.CurrentInputFileMinChars(),
|
||||
},
|
||||
"thinking_injection": map[string]any{
|
||||
"enabled": h.Store.ThinkingInjectionEnabled(),
|
||||
"enabled": h.Store.ThinkingInjectionEnabled(),
|
||||
"prompt": h.Store.ThinkingInjectionPrompt(),
|
||||
"default_prompt": promptcompat.DefaultThinkingInjectionPrompt,
|
||||
},
|
||||
"model_aliases": snap.ModelAliases,
|
||||
"env_backed": h.Store.IsEnvBacked(),
|
||||
|
||||
@@ -89,6 +89,7 @@ func (h *Handler) updateSettings(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
if thinkingInjCfg != nil {
|
||||
c.ThinkingInjection.Enabled = thinkingInjCfg.Enabled
|
||||
c.ThinkingInjection.Prompt = thinkingInjCfg.Prompt
|
||||
}
|
||||
if aliasMap != nil {
|
||||
c.ModelAliases = aliasMap
|
||||
|
||||
@@ -38,6 +38,7 @@ type ConfigStore interface {
|
||||
CurrentInputFileEnabled() bool
|
||||
CurrentInputFileMinChars() int
|
||||
ThinkingInjectionEnabled() bool
|
||||
ThinkingInjectionPrompt() string
|
||||
CompatStripReferenceMarkers() bool
|
||||
AutoDeleteSessions() bool
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ type mockOpenAIConfig struct {
|
||||
currentInputEnabled bool
|
||||
currentInputMin int
|
||||
thinkingInjection *bool
|
||||
thinkingPrompt string
|
||||
}
|
||||
|
||||
func (m mockOpenAIConfig) ModelAliases() map[string]string { return m.aliases }
|
||||
@@ -58,6 +59,7 @@ func (m mockOpenAIConfig) ThinkingInjectionEnabled() bool {
|
||||
}
|
||||
return *m.thinkingInjection
|
||||
}
|
||||
func (m mockOpenAIConfig) ThinkingInjectionPrompt() string { return m.thinkingPrompt }
|
||||
|
||||
type streamStatusAuthStub struct{}
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ type mockOpenAIConfig struct {
|
||||
currentInputEnabled bool
|
||||
currentInputMin int
|
||||
thinkingInjection *bool
|
||||
thinkingPrompt string
|
||||
}
|
||||
|
||||
func (m mockOpenAIConfig) ModelAliases() map[string]string { return m.aliases }
|
||||
@@ -54,6 +55,7 @@ func (m mockOpenAIConfig) ThinkingInjectionEnabled() bool {
|
||||
}
|
||||
return *m.thinkingInjection
|
||||
}
|
||||
func (m mockOpenAIConfig) ThinkingInjectionPrompt() string { return m.thinkingPrompt }
|
||||
|
||||
func TestNormalizeOpenAIChatRequestWithConfigInterface(t *testing.T) {
|
||||
cfg := mockOpenAIConfig{
|
||||
|
||||
@@ -183,6 +183,36 @@ func TestApplyThinkingInjectionAppendsLatestUserPrompt(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyThinkingInjectionUsesCustomPrompt(t *testing.T) {
|
||||
ds := &inlineUploadDSStub{}
|
||||
h := &openAITestSurface{
|
||||
Store: mockOpenAIConfig{
|
||||
wideInput: true,
|
||||
thinkingInjection: boolPtr(true),
|
||||
thinkingPrompt: "custom thinking format",
|
||||
},
|
||||
DS: ds,
|
||||
}
|
||||
req := map[string]any{
|
||||
"model": "deepseek-v4-flash",
|
||||
"messages": []any{
|
||||
map[string]any{"role": "user", "content": "hello"},
|
||||
},
|
||||
}
|
||||
stdReq, err := promptcompat.NormalizeOpenAIChatRequest(h.Store, req, "")
|
||||
if err != nil {
|
||||
t.Fatalf("normalize failed: %v", err)
|
||||
}
|
||||
|
||||
out, err := h.applyHistorySplit(context.Background(), &auth.RequestAuth{DeepSeekToken: "token"}, stdReq)
|
||||
if err != nil {
|
||||
t.Fatalf("apply thinking injection failed: %v", err)
|
||||
}
|
||||
if !strings.Contains(out.FinalPrompt, "hello\n\ncustom thinking format") {
|
||||
t.Fatalf("expected custom thinking injection after latest user message, got %s", out.FinalPrompt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyHistorySplitDirectPassThroughWhenBothSplitsDisabled(t *testing.T) {
|
||||
ds := &inlineUploadDSStub{}
|
||||
h := &openAITestSurface{
|
||||
|
||||
@@ -48,6 +48,7 @@ type ConfigReader interface {
|
||||
CurrentInputFileEnabled() bool
|
||||
CurrentInputFileMinChars() int
|
||||
ThinkingInjectionEnabled() bool
|
||||
ThinkingInjectionPrompt() string
|
||||
}
|
||||
|
||||
type Deps struct {
|
||||
|
||||
@@ -6,7 +6,7 @@ func ApplyThinkingInjection(store ConfigReader, stdReq promptcompat.StandardRequ
|
||||
if store == nil || !store.ThinkingInjectionEnabled() || !stdReq.Thinking {
|
||||
return stdReq
|
||||
}
|
||||
messages, changed := promptcompat.AppendThinkingInjectionToLatestUser(stdReq.Messages)
|
||||
messages, changed := promptcompat.AppendThinkingInjectionPromptToLatestUser(stdReq.Messages, store.ThinkingInjectionPrompt())
|
||||
if !changed {
|
||||
return stdReq
|
||||
}
|
||||
|
||||
@@ -12,9 +12,17 @@ const (
|
||||
)
|
||||
|
||||
func AppendThinkingInjectionToLatestUser(messages []any) ([]any, bool) {
|
||||
return AppendThinkingInjectionPromptToLatestUser(messages, "")
|
||||
}
|
||||
|
||||
func AppendThinkingInjectionPromptToLatestUser(messages []any, injectionPrompt string) ([]any, bool) {
|
||||
if len(messages) == 0 {
|
||||
return messages, false
|
||||
}
|
||||
injectionPrompt = strings.TrimSpace(injectionPrompt)
|
||||
if injectionPrompt == "" {
|
||||
injectionPrompt = DefaultThinkingInjectionPrompt
|
||||
}
|
||||
for i := len(messages) - 1; i >= 0; i-- {
|
||||
msg, ok := messages[i].(map[string]any)
|
||||
if !ok {
|
||||
@@ -24,10 +32,11 @@ func AppendThinkingInjectionToLatestUser(messages []any) ([]any, bool) {
|
||||
continue
|
||||
}
|
||||
content := msg["content"]
|
||||
if strings.Contains(NormalizeOpenAIContentForPrompt(content), ThinkingInjectionMarker) {
|
||||
normalizedContent := NormalizeOpenAIContentForPrompt(content)
|
||||
if strings.Contains(normalizedContent, ThinkingInjectionMarker) || strings.Contains(normalizedContent, injectionPrompt) {
|
||||
return messages, false
|
||||
}
|
||||
updatedContent := appendThinkingInjectionToContent(content)
|
||||
updatedContent := appendThinkingInjectionToContent(content, injectionPrompt)
|
||||
out := append([]any(nil), messages...)
|
||||
cloned := make(map[string]any, len(msg))
|
||||
for k, v := range msg {
|
||||
@@ -40,20 +49,20 @@ func AppendThinkingInjectionToLatestUser(messages []any) ([]any, bool) {
|
||||
return messages, false
|
||||
}
|
||||
|
||||
func appendThinkingInjectionToContent(content any) any {
|
||||
func appendThinkingInjectionToContent(content any, injectionPrompt string) any {
|
||||
switch x := content.(type) {
|
||||
case string:
|
||||
return appendTextBlock(x, DefaultThinkingInjectionPrompt)
|
||||
return appendTextBlock(x, injectionPrompt)
|
||||
case []any:
|
||||
out := append([]any(nil), x...)
|
||||
out = append(out, map[string]any{
|
||||
"type": "text",
|
||||
"text": DefaultThinkingInjectionPrompt,
|
||||
"text": injectionPrompt,
|
||||
})
|
||||
return out
|
||||
default:
|
||||
text := NormalizeOpenAIContentForPrompt(content)
|
||||
return appendTextBlock(text, DefaultThinkingInjectionPrompt)
|
||||
return appendTextBlock(text, injectionPrompt)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -51,6 +51,21 @@ func TestAppendThinkingInjectionToLatestUserArrayContent(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppendThinkingInjectionToLatestUserCustomPrompt(t *testing.T) {
|
||||
messages := []any{
|
||||
map[string]any{"role": "user", "content": "latest"},
|
||||
}
|
||||
|
||||
out, changed := AppendThinkingInjectionPromptToLatestUser(messages, "custom thinking format")
|
||||
if !changed {
|
||||
t.Fatal("expected custom thinking injection to be appended")
|
||||
}
|
||||
content, _ := out[0].(map[string]any)["content"].(string)
|
||||
if !strings.Contains(content, "latest\n\ncustom thinking format") {
|
||||
t.Fatalf("expected custom injection after latest user text, got %q", content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppendThinkingInjectionToLatestUserSkipsDuplicate(t *testing.T) {
|
||||
messages := []any{
|
||||
map[string]any{"role": "user", "content": "latest\n\n" + DefaultThinkingInjectionPrompt},
|
||||
|
||||
@@ -46,6 +46,23 @@ export default function BehaviorSection({ t, form, setForm }) {
|
||||
<span className="text-xs text-muted-foreground block">{t('settings.thinkingInjectionDesc')}</span>
|
||||
</div>
|
||||
</label>
|
||||
<label className="text-sm space-y-2 md:col-span-2">
|
||||
<span className="text-muted-foreground">{t('settings.thinkingInjectionPrompt')}</span>
|
||||
<textarea
|
||||
rows={5}
|
||||
value={form.thinking_injection?.prompt || ''}
|
||||
placeholder={form.thinking_injection?.default_prompt || ''}
|
||||
onChange={(e) => setForm((prev) => ({
|
||||
...prev,
|
||||
thinking_injection: {
|
||||
...prev.thinking_injection,
|
||||
prompt: e.target.value,
|
||||
},
|
||||
}))}
|
||||
className="w-full bg-background border border-border rounded-lg px-3 py-2 resize-y min-h-32"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">{t('settings.thinkingInjectionPromptHelp')}</p>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -19,7 +19,7 @@ const DEFAULT_FORM = {
|
||||
auto_delete: { mode: 'none' },
|
||||
history_split: { enabled: false, trigger_after_turns: 1 },
|
||||
current_input_file: { enabled: true, min_chars: 0 },
|
||||
thinking_injection: { enabled: true },
|
||||
thinking_injection: { enabled: true, prompt: '', default_prompt: '' },
|
||||
model_aliases_text: '{}',
|
||||
}
|
||||
|
||||
@@ -84,6 +84,8 @@ function fromServerForm(data) {
|
||||
},
|
||||
thinking_injection: {
|
||||
enabled: data.thinking_injection?.enabled ?? true,
|
||||
prompt: data.thinking_injection?.prompt || '',
|
||||
default_prompt: data.thinking_injection?.default_prompt || '',
|
||||
},
|
||||
model_aliases_text: JSON.stringify(data.model_aliases || {}, null, 2),
|
||||
}
|
||||
@@ -116,6 +118,7 @@ function toServerPayload(form) {
|
||||
},
|
||||
thinking_injection: {
|
||||
enabled: Boolean(form.thinking_injection?.enabled ?? true),
|
||||
prompt: String(form.thinking_injection?.prompt || '').trim(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -375,6 +375,8 @@
|
||||
"embeddingsProvider": "Embeddings provider",
|
||||
"thinkingInjectionEnabled": "Thinking format injection",
|
||||
"thinkingInjectionDesc": "Append a structured <think> checklist to the latest user message before prompt assembly.",
|
||||
"thinkingInjectionPrompt": "Thinking format prompt",
|
||||
"thinkingInjectionPromptHelp": "Leave empty to use the built-in default prompt shown as the input placeholder.",
|
||||
"historySplitTitle": "Context Split",
|
||||
"historySplitDesc": "Choose one context-splitting mode to avoid inlining very long prompts.",
|
||||
"historySplitEnabled": "Turn split (second turn by default)",
|
||||
|
||||
@@ -375,6 +375,8 @@
|
||||
"embeddingsProvider": "Embeddings Provider",
|
||||
"thinkingInjectionEnabled": "思考格式注入",
|
||||
"thinkingInjectionDesc": "在组装 prompt 前,将结构化 <think> 检查清单追加到最新用户消息末尾。",
|
||||
"thinkingInjectionPrompt": "思考格式提示词",
|
||||
"thinkingInjectionPromptHelp": "留空时使用内置默认提示词;默认内容会显示在输入框占位文本中。",
|
||||
"historySplitTitle": "上下文拆分",
|
||||
"historySplitDesc": "选择一种上下文拆分方式,减少超长 prompt 直接内联。",
|
||||
"historySplitEnabled": "轮次拆分(默认第二轮)",
|
||||
|
||||
Reference in New Issue
Block a user