feat(prompt): align DeepSeek prompt assembly with tokenizer-style turns

This commit is contained in:
CJACK.
2026-04-12 13:59:42 +08:00
parent 792e295512
commit 433a3a877d
10 changed files with 56 additions and 21 deletions

View File

@@ -36,7 +36,7 @@ func normalizeClaudeRequest(store ConfigReader, req map[string]any) (claudeNorma
thinkingEnabled = false
searchEnabled = false
}
finalPrompt := deepseek.MessagesPrepare(toMessageMaps(dsPayload["messages"]))
finalPrompt := deepseek.MessagesPrepareWithThinking(toMessageMaps(dsPayload["messages"]), thinkingEnabled)
toolNames := extractClaudeToolNames(toolsRequested)
if len(toolNames) == 0 && len(toolsRequested) > 0 {
toolNames = []string{"__any_tool__"}

View File

@@ -28,7 +28,7 @@ func normalizeGeminiRequest(store ConfigReader, routeModel string, req map[strin
}
toolsRaw := convertGeminiTools(req["tools"])
finalPrompt, toolNames := openai.BuildPromptForAdapter(messagesRaw, toolsRaw, "")
finalPrompt, toolNames := openai.BuildPromptForAdapter(messagesRaw, toolsRaw, "", thinkingEnabled)
passThrough := collectGeminiPassThrough(req)
return util.StandardRequest{

View File

@@ -5,22 +5,22 @@ import (
"ds2api/internal/util"
)
func buildOpenAIFinalPrompt(messagesRaw []any, toolsRaw any, traceID string) (string, []string) {
return buildOpenAIFinalPromptWithPolicy(messagesRaw, toolsRaw, traceID, util.DefaultToolChoicePolicy())
func buildOpenAIFinalPrompt(messagesRaw []any, toolsRaw any, traceID string, thinkingEnabled bool) (string, []string) {
return buildOpenAIFinalPromptWithPolicy(messagesRaw, toolsRaw, traceID, util.DefaultToolChoicePolicy(), thinkingEnabled)
}
func buildOpenAIFinalPromptWithPolicy(messagesRaw []any, toolsRaw any, traceID string, toolPolicy util.ToolChoicePolicy) (string, []string) {
func buildOpenAIFinalPromptWithPolicy(messagesRaw []any, toolsRaw any, traceID string, toolPolicy util.ToolChoicePolicy, thinkingEnabled bool) (string, []string) {
messages := normalizeOpenAIMessagesForPrompt(messagesRaw, traceID)
toolNames := []string{}
if tools, ok := toolsRaw.([]any); ok && len(tools) > 0 {
messages, toolNames = injectToolPrompt(messages, tools, toolPolicy)
}
return deepseek.MessagesPrepare(messages), toolNames
return deepseek.MessagesPrepareWithThinking(messages, thinkingEnabled), toolNames
}
// BuildPromptForAdapter exposes the OpenAI-compatible prompt building flow so
// other protocol adapters (for example Gemini) can reuse the same tool/history
// normalization logic and remain behavior-compatible with chat/completions.
func BuildPromptForAdapter(messagesRaw []any, toolsRaw any, traceID string) (string, []string) {
return buildOpenAIFinalPrompt(messagesRaw, toolsRaw, traceID)
func BuildPromptForAdapter(messagesRaw []any, toolsRaw any, traceID string, thinkingEnabled bool) (string, []string) {
return buildOpenAIFinalPrompt(messagesRaw, toolsRaw, traceID, thinkingEnabled)
}

View File

@@ -40,7 +40,7 @@ func TestBuildOpenAIFinalPrompt_HandlerPathIncludesToolRoundtripSemantics(t *tes
},
}
finalPrompt, toolNames := buildOpenAIFinalPrompt(messages, tools, "")
finalPrompt, toolNames := buildOpenAIFinalPrompt(messages, tools, "", false)
if len(toolNames) != 1 || toolNames[0] != "get_weather" {
t.Fatalf("unexpected tool names: %#v", toolNames)
}
@@ -73,7 +73,7 @@ func TestBuildOpenAIFinalPrompt_VercelPreparePathKeepsFinalAnswerInstruction(t *
},
}
finalPrompt, _ := buildOpenAIFinalPrompt(messages, tools, "")
finalPrompt, _ := buildOpenAIFinalPrompt(messages, tools, "", false)
if !strings.Contains(finalPrompt, "Remember: Output ONLY the <tool_calls>...</tool_calls> XML block when calling tools.") {
t.Fatalf("vercel prepare finalPrompt missing final tool-call anchor instruction: %q", finalPrompt)
}

View File

@@ -24,7 +24,7 @@ func normalizeOpenAIChatRequest(store ConfigReader, req map[string]any, traceID
responseModel = resolvedModel
}
toolPolicy := util.DefaultToolChoicePolicy()
finalPrompt, toolNames := buildOpenAIFinalPromptWithPolicy(messagesRaw, req["tools"], traceID, toolPolicy)
finalPrompt, toolNames := buildOpenAIFinalPromptWithPolicy(messagesRaw, req["tools"], traceID, toolPolicy, thinkingEnabled)
toolNames = ensureToolDetectionEnabled(toolNames, req["tools"])
passThrough := collectOpenAIChatPassThrough(req)
@@ -74,7 +74,7 @@ func normalizeOpenAIResponsesRequest(store ConfigReader, req map[string]any, tra
if err != nil {
return util.StandardRequest{}, err
}
finalPrompt, toolNames := buildOpenAIFinalPromptWithPolicy(messagesRaw, req["tools"], traceID, toolPolicy)
finalPrompt, toolNames := buildOpenAIFinalPromptWithPolicy(messagesRaw, req["tools"], traceID, toolPolicy, thinkingEnabled)
toolNames = ensureToolDetectionEnabled(toolNames, req["tools"])
if !toolPolicy.IsNone() {
toolPolicy.Allowed = namesToSet(toolNames)

View File

@@ -5,3 +5,7 @@ import "ds2api/internal/prompt"
func MessagesPrepare(messages []map[string]any) string {
return prompt.MessagesPrepare(messages)
}
func MessagesPrepareWithThinking(messages []map[string]any, thinkingEnabled bool) string {
return prompt.MessagesPrepareWithThinking(messages, thinkingEnabled)
}

View File

@@ -10,6 +10,7 @@ import (
var markdownImagePattern = regexp.MustCompile(`!\[(.*?)\]\((.*?)\)`)
const (
beginSentenceMarker = "<begin▁of▁sentence>"
systemMarker = "<System>"
userMarker = "<User>"
assistantMarker = "<Assistant>"
@@ -17,9 +18,15 @@ const (
endSentenceMarker = "<end▁of▁sentence>"
endToolResultsMarker = "<end▁of▁toolresults>"
endInstructionsMarker = "<end▁of▁instructions>"
openThinkMarker = "<think>"
closeThinkMarker = "</think>"
)
func MessagesPrepare(messages []map[string]any) string {
return MessagesPrepareWithThinking(messages, false)
}
func MessagesPrepareWithThinking(messages []map[string]any, thinkingEnabled bool) string {
type block struct {
Role string
Text string
@@ -41,11 +48,14 @@ func MessagesPrepare(messages []map[string]any) string {
}
merged = append(merged, msg)
}
parts := make([]string, 0, len(merged))
parts := make([]string, 0, len(merged)+2)
parts = append(parts, beginSentenceMarker)
lastRole := ""
for _, m := range merged {
lastRole = m.Role
switch m.Role {
case "assistant":
parts = append(parts, formatRoleBlock(assistantMarker, m.Text, endSentenceMarker))
parts = append(parts, formatRoleBlock(assistantMarker, closeThinkMarker+m.Text, endSentenceMarker))
case "tool":
if strings.TrimSpace(m.Text) != "" {
parts = append(parts, formatRoleBlock(toolMarker, m.Text, endToolResultsMarker))
@@ -62,6 +72,13 @@ func MessagesPrepare(messages []map[string]any) string {
}
}
}
if lastRole != "assistant" {
thinkPrefix := closeThinkMarker
if thinkingEnabled {
thinkPrefix = openThinkMarker
}
parts = append(parts, assistantMarker+thinkPrefix)
}
out := strings.Join(parts, "\n\n")
return markdownImagePattern.ReplaceAllString(out, `[${1}](${2})`)
}

View File

@@ -32,13 +32,16 @@ func TestMessagesPrepareUsesTurnSuffixes(t *testing.T) {
{"role": "assistant", "content": "Answer"},
}
got := MessagesPrepare(messages)
if !strings.HasPrefix(got, "<begin▁of▁sentence>") {
t.Fatalf("expected begin-of-sentence marker, got %q", got)
}
if !strings.Contains(got, "<System>\nSystem rule<end▁of▁instructions>") {
t.Fatalf("expected system instructions suffix, got %q", got)
}
if !strings.Contains(got, "<User>\nQuestion<end▁of▁sentence>") {
t.Fatalf("expected user sentence suffix, got %q", got)
}
if !strings.Contains(got, "<Assistant>\nAnswer<end▁of▁sentence>") {
if !strings.Contains(got, "<Assistant>\n</think>Answer<end▁of▁sentence>") {
t.Fatalf("expected assistant sentence suffix, got %q", got)
}
}
@@ -51,3 +54,11 @@ func TestNormalizeContentArrayFallsBackToContentWhenTextEmpty(t *testing.T) {
t.Fatalf("expected fallback to content when text is empty, got %q", got)
}
}
func TestMessagesPrepareWithThinkingEndsWithOpenThink(t *testing.T) {
messages := []map[string]any{{"role": "user", "content": "Question"}}
got := MessagesPrepareWithThinking(messages, true)
if !strings.HasSuffix(got, "<Assistant><think>") {
t.Fatalf("expected thinking suffix, got %q", got)
}
}

View File

@@ -12,7 +12,7 @@ func TestMessagesPrepareBasic(t *testing.T) {
if got == "" {
t.Fatal("expected non-empty prompt")
}
if got != "<User>\nHello<end▁of▁sentence>" {
if got != "<begin▁of▁sentence>\n\n<User>\nHello<end▁of▁sentence>\n\n<Assistant></think>" {
t.Fatalf("unexpected prompt: %q", got)
}
}
@@ -29,10 +29,13 @@ func TestMessagesPrepareRoles(t *testing.T) {
if !contains(got, "<System>\nYou are helper<end▁of▁instructions>\n\n<User>\nHi<end▁of▁sentence>") {
t.Fatalf("expected system/user separation in %q", got)
}
if !contains(got, "<User>\nHi<end▁of▁sentence>\n\n<Assistant>\nHello<end▁of▁sentence>") {
if !contains(got, "<begin▁of▁sentence>") {
t.Fatalf("expected begin marker in %q", got)
}
if !contains(got, "<User>\nHi<end▁of▁sentence>\n\n<Assistant>\n</think>Hello<end▁of▁sentence>") {
t.Fatalf("expected user/assistant separation in %q", got)
}
if !contains(got, "<Assistant>\nHello<end▁of▁sentence>\n\n<Tool>\nSearch results<end▁of▁toolresults>") {
if !contains(got, "<Assistant>\n</think>Hello<end▁of▁sentence>\n\n<Tool>\nSearch results<end▁of▁toolresults>") {
t.Fatalf("expected assistant/tool separation in %q", got)
}
if !contains(got, "<Tool>\nSearch results<end▁of▁toolresults>\n\n<User>\nHow are you<end▁of▁sentence>") {
@@ -74,7 +77,7 @@ func TestMessagesPrepareArrayTextVariants(t *testing.T) {
},
}
got := MessagesPrepare(messages)
if got != "<User>\nline1\nline2<end▁of▁sentence>" {
if got != "<begin▁of▁sentence>\n\n<User>\nline1\nline2<end▁of▁sentence>\n\n<Assistant></think>" {
t.Fatalf("unexpected content from text variants: %q", got)
}
}

View File

@@ -162,7 +162,7 @@ func TestMessagesPrepareMergesConsecutiveSameRole(t *testing.T) {
{"role": "user", "content": "World"},
}
got := MessagesPrepare(messages)
if !strings.HasPrefix(got, "<User>") {
if !strings.HasPrefix(got, "<begin▁of▁sentence>") {
t.Fatalf("expected user marker at the start, got %q", got)
}
if !strings.Contains(got, "Hello") || !strings.Contains(got, "World") {
@@ -193,7 +193,7 @@ func TestMessagesPrepareAssistantMarkers(t *testing.T) {
if strings.Count(got, "<end▁of▁sentence>") != 2 {
t.Fatalf("expected both turns to be terminated, got %q", got)
}
if !strings.Contains(got, "<Assistant>\nHello!<end▁of▁sentence>") {
if !strings.Contains(got, "<Assistant>\n</think>Hello!<end▁of▁sentence>") {
t.Fatalf("expected assistant EOS suffix, got %q", got)
}
if strings.Contains(got, "<system_instructions>") {