修复 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:
Your Name
2026-09-04 10:50:25 +08:00
co-authored by Claude Opus 5
parent acd8933dbb
commit 812d8032e1
4 changed files with 235 additions and 1 deletions
@@ -4,6 +4,10 @@ import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
@@ -168,3 +172,77 @@ func TestDefaultAIModelAcceptsNameOrIDMySQL(t *testing.T) {
t.Fatalf("留空时使用标记为默认的一条: id = %d", model.ID)
}
}
// End to end over a real database and a real HTTP model: the first call comes
// back blank because the reasoning ate the budget, and the reply still has to
// arrive.
func TestAIReplyRetriesAfterReasoningExhaustsBudgetMySQL(t *testing.T) {
db := isolatedIMDatabase(t)
a := &App{db: db, hub: NewHub(), config: Config{Environment: "development", JWTSecret: "isolated-im-regression-secret-only"}}
budgets := []int{}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var body map[string]any
raw, _ := io.ReadAll(io.LimitReader(r.Body, 1<<20))
_ = json.Unmarshal(raw, &body)
budgets = append(budgets, int(body["max_tokens"].(float64)))
w.Header().Set("Content-Type", "application/json")
if len(budgets) == 1 {
_, _ = w.Write([]byte(`{"id":"a","choices":[{"message":{"content":"","reasoning_content":"想一想"},"finish_reason":"length"}],"usage":{"prompt_tokens":240,"completion_tokens":256}}`))
return
}
_, _ = w.Write([]byte(`{"id":"b","choices":[{"message":{"content":"在的,刚忙完","reasoning_content":"想好了"},"finish_reason":"stop"}],"usage":{"prompt_tokens":240,"completion_tokens":40}}`))
}))
defer server.Close()
for _, statement := range []string{
`INSERT INTO system_configs(config_key,config_value,value_type,description) VALUES('ai.enabled','true','boolean',''),('ai.disclose_in_chat','false','boolean','') ON DUPLICATE KEY UPDATE config_value=VALUES(config_value)`,
`UPDATE users SET is_test=1,test_batch='regression' WHERE id=2`,
`INSERT INTO ai_agents(user_id,model_id,enabled) VALUES(2,1,1)`,
} {
if _, err := db.Exec(statement); err != nil {
t.Fatal(err)
}
}
cipher, err := a.encryptSecret("test-key")
if err != nil {
t.Fatal(err)
}
// max_tokens 256 is the setting that produced blank replies in production.
if _, err := db.Exec(`INSERT INTO ai_models(id,name,protocol,base_url,model_name,api_key_cipher,temperature,max_tokens,timeout_ms,status,is_default) VALUES(1,'reasoning','openai',?,'reasoning-model',?,0.80,256,8000,1,1)`, server.URL, cipher); err != nil {
t.Fatal(err)
}
var created struct {
ID int64 `json:"id"`
}
if err := json.Unmarshal(imTestCall(t, a.directConversation, 1, "POST", "/api/v1/im/conversations", `{"userId":2}`, 200), &created); err != nil {
t.Fatal(err)
}
if _, _, err := a.persistMessageContext(context.Background(), created.ID, 1, "trigger", 1, map[string]any{"text": "在吗"}); err != nil {
t.Fatal(err)
}
var job aiReplyJob
if err := db.QueryRow(`SELECT id,conversation_id,agent_user_id,attempts FROM ai_reply_jobs WHERE conversation_id=?`, created.ID).
Scan(&job.ID, &job.ConversationID, &job.AgentUserID, &job.Attempts); err != nil {
t.Fatal(err)
}
if err := a.processAIReplyJob(context.Background(), job); err != nil {
t.Fatalf("推理耗尽预算后应当重试成功: %v", err)
}
if len(budgets) != 2 || budgets[0] != 256 || budgets[1] != 1024 {
t.Fatalf("budgets = %v, want [256 1024]", budgets)
}
var text string
if err := db.QueryRow(`SELECT CAST(body AS CHAR) FROM im_messages WHERE conversation_id=? AND sender_id=2 ORDER BY id DESC LIMIT 1`, created.ID).Scan(&text); err != nil {
t.Fatal(err)
}
if !strings.Contains(text, "刚忙完") {
t.Fatalf("回复没有落库: %s", text)
}
// Both attempts are on the record, so the cost of the retry is visible.
var calls int
if err := db.QueryRow(`SELECT COUNT(*) FROM ai_call_logs WHERE conversation_id=?`, created.ID).Scan(&calls); err != nil {
t.Fatal(err)
}
if calls != 2 {
t.Fatalf("calls = %d, want 2", calls)
}
}