修复 AI 回复为空与开头数字被吞
日志里一批「成功但回复为空」的调用,completion tokens 恰好等于配置的 上限 256:推理模型把预算全花在思维链上,正文没写出来就被截断。 - 解析 OpenAI 兼容响应时读取 reasoning_content,并据此与 finish_reason 判断「被截断」。 - 被截断时自动加大预算重试一次(4 倍,下限 1024、上限 4096),两次调用 都记入日志以便核算成本;重试后仍为空才报错,并说明是推理占满预算, 提示调高 tokens 或改用非推理模型。 - sanitizeAIReply 原本用 TrimLeft 去掉列表符号,连同任何开头的数字一起 吃掉了——「30岁啦」发出去变成「岁啦」。改为只匹配真正的列表前缀 (- * • 或 "1. " "2、"),号码、年龄、时长都不再受损。 线上两台已将模型 max_tokens 由 256 调整为 2048。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
acd8933dbb
commit
812d8032e1
@@ -216,3 +216,107 @@ func TestDebugProviderIsDeterministicAndOffline(t *testing.T) {
|
||||
t.Fatal("debug provider must answer even without history")
|
||||
}
|
||||
}
|
||||
|
||||
// A reasoning model answers in two parts. When the token ceiling is reached
|
||||
// before it stops thinking, the reply field is empty and the whole budget shows
|
||||
// up as completion tokens — exactly what the admin log showed as "成功" with a
|
||||
// blank reply.
|
||||
func TestReasoningModelExhaustingItsBudgetIsRecognised(t *testing.T) {
|
||||
server, _, _ := captureAIRequest(t, `{"id":"call-1","choices":[{"message":{"content":"","reasoning_content":"先想想怎么回答"},"finish_reason":"length"}],"usage":{"prompt_tokens":240,"completion_tokens":256}}`, 200)
|
||||
result, err := chatOnce(t, "openai", server.URL)
|
||||
if err != nil {
|
||||
t.Fatalf("空正文本身不是传输错误: %v", err)
|
||||
}
|
||||
if result.Text != "" || result.Reasoning != "先想想怎么回答" {
|
||||
t.Fatalf("result = %+v", result)
|
||||
}
|
||||
if !result.truncated() {
|
||||
t.Fatal("必须能识别出被截断")
|
||||
}
|
||||
|
||||
// The ordinary case must not be mistaken for it.
|
||||
plain, _, _ := captureAIRequest(t, `{"id":"call-2","choices":[{"message":{"content":"在的,刚吃过"},"finish_reason":"stop"}],"usage":{"prompt_tokens":240,"completion_tokens":62}}`, 200)
|
||||
answered, err := chatOnce(t, "openai", plain.URL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if answered.Text != "在的,刚吃过" || answered.truncated() {
|
||||
t.Fatalf("result = %+v", answered)
|
||||
}
|
||||
|
||||
// An empty reply with no reasoning and a normal stop is still just empty.
|
||||
blank, _, _ := captureAIRequest(t, `{"id":"call-3","choices":[{"message":{"content":""},"finish_reason":"stop"}],"usage":{"prompt_tokens":10,"completion_tokens":0}}`, 200)
|
||||
empty, err := chatOnce(t, "openai", blank.URL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if empty.truncated() {
|
||||
t.Fatal("普通空回复不该被当成截断")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAIRetryMaxTokensStaysWithinBounds(t *testing.T) {
|
||||
for _, test := range []struct{ current, want int }{
|
||||
{256, 1024}, // the configured value that produced blank replies
|
||||
{64, 1024}, // never retry with less than a usable budget
|
||||
{512, 2048}, // four times when that is already sensible
|
||||
{2048, 4096}, // capped, so a misconfiguration cannot bill without limit
|
||||
{8192, 4096},
|
||||
} {
|
||||
if got := aiRetryMaxTokens(test.current); got != test.want {
|
||||
t.Fatalf("aiRetryMaxTokens(%d) = %d, want %d", test.current, got, test.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The request the retry sends has to actually carry the raised ceiling.
|
||||
func TestChatRequestMaxTokensOverridesTheModelConfiguration(t *testing.T) {
|
||||
server, body, _ := captureAIRequest(t, `{"id":"c","choices":[{"message":{"content":"好"},"finish_reason":"stop"}],"usage":{}}`, 200)
|
||||
provider, err := newAIProvider(testModel("openai", server.URL), false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err = provider.Chat(context.Background(), aiChatRequest{
|
||||
Messages: []aiChatMessage{{Role: "user", Text: "在吗"}},
|
||||
MaxTokens: aiRetryMaxTokens(128),
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := (*body)["max_tokens"]; got != float64(1024) {
|
||||
t.Fatalf("max_tokens = %v, want 1024", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A reply that opens with a number used to arrive with the number missing: the
|
||||
// list-marker trimming removed the digits themselves.
|
||||
func TestSanitizeAIReplyKeepsLeadingNumbers(t *testing.T) {
|
||||
for _, test := range []struct{ raw, want string }{
|
||||
{"30岁啦,你呢", "30岁啦,你呢"},
|
||||
{"1. 先自我介绍", "先自我介绍"},
|
||||
{"2、我在深圳", "我在深圳"},
|
||||
{"- 喜欢跑步", "喜欢跑步"},
|
||||
{"• 也喜欢音乐", "也喜欢音乐"},
|
||||
{"**在的**", "在的"},
|
||||
{"18612725546 是我的号码", "18612725546 是我的号码"},
|
||||
{"3 个月前搬来的", "3 个月前搬来的"},
|
||||
} {
|
||||
if got := sanitizeAIReply(test.raw); got != test.want {
|
||||
t.Fatalf("sanitizeAIReply(%q) = %q, want %q", test.raw, got, test.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A cut-off answer is worth one more attempt too, not just an empty one.
|
||||
func TestTruncatedAnswerIsRetriedAndKeptIfItSurvives(t *testing.T) {
|
||||
cut, _, _ := captureAIRequest(t, `{"id":"c","choices":[{"message":{"content":"30岁啦,"},"finish_reason":"length"}],"usage":{"prompt_tokens":240,"completion_tokens":1024}}`, 200)
|
||||
result, err := chatOnce(t, "openai", cut.URL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !result.truncated() {
|
||||
t.Fatal("被截断的正文也要触发重试")
|
||||
}
|
||||
if sanitizeAIReply(result.Text) != "30岁啦," {
|
||||
t.Fatalf("text = %q", result.Text)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user