修复 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
@@ -8,6 +8,7 @@ import (
|
||||
"fmt"
|
||||
"log"
|
||||
"math/rand"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -258,14 +259,31 @@ func (a *App) processAIReplyJob(ctx context.Context, job aiReplyJob) error {
|
||||
result, callErr := provider.Chat(callCtx, request)
|
||||
latency := int(time.Since(started).Milliseconds())
|
||||
a.logAICall(ctx, model.ID, "chat", job.AgentUserID, job.ConversationID, latency, result, callErr)
|
||||
// Reasoning models spend the budget thinking before they answer. With a
|
||||
// small ceiling the call "succeeds" with nothing to say, so give it room and
|
||||
// ask once more rather than logging a silent empty reply.
|
||||
if callErr == nil && result.truncated() {
|
||||
retry := request
|
||||
retry.MaxTokens = aiRetryMaxTokens(aiRequestMaxTokens(request, model))
|
||||
retryCtx, cancelRetry := context.WithTimeout(ctx, time.Duration(model.TimeoutMS+2000)*time.Millisecond)
|
||||
started = time.Now()
|
||||
result, callErr = provider.Chat(retryCtx, retry)
|
||||
cancelRetry()
|
||||
a.logAICall(ctx, model.ID, "chat", job.AgentUserID, job.ConversationID, int(time.Since(started).Milliseconds()), result, callErr)
|
||||
}
|
||||
if callErr != nil {
|
||||
if fallback := strings.TrimSpace(a.configPlain(ctx, "ai.fallback_text", "")); fallback != "" {
|
||||
_ = a.sendAIReply(ctx, job, fallback)
|
||||
}
|
||||
return callErr
|
||||
}
|
||||
// A short answer that survived the retry is still worth sending; only an
|
||||
// empty one is a failure.
|
||||
text := sanitizeAIReply(result.Text)
|
||||
if text == "" {
|
||||
if result.truncated() {
|
||||
return fmt.Errorf("模型把 %d tokens 全部用于推理,未产出正文;请在模型配置中调高最大 tokens,或改用非推理模型", result.CompletionTokens)
|
||||
}
|
||||
return fmt.Errorf("模型返回内容为空")
|
||||
}
|
||||
if first && a.configBool(ctx, "ai.disclose_in_chat", true) {
|
||||
@@ -433,6 +451,11 @@ func aiSystemPrompt(binding aiAgentBinding, disclose bool) string {
|
||||
}
|
||||
|
||||
// Models drift into assistant formatting; the chat bubble wants one plain line.
|
||||
// Only an actual list marker is removed. Trimming the digit characters
|
||||
// themselves ate the number out of every reply that opened with one — "30岁啦"
|
||||
// arrived as "岁啦".
|
||||
var aiListMarker = regexp.MustCompile(`^(?:[-*•]+\s*|\d{1,2}(?:[.)]\s+|、\s*))`)
|
||||
|
||||
func sanitizeAIReply(text string) string {
|
||||
text = strings.TrimSpace(text)
|
||||
if text == "" {
|
||||
@@ -445,7 +468,7 @@ func sanitizeAIReply(text string) string {
|
||||
text = replacer.Replace(text)
|
||||
lines := []string{}
|
||||
for _, line := range strings.Split(text, "\n") {
|
||||
line = strings.TrimSpace(strings.TrimLeft(strings.TrimSpace(line), "-*•0123456789. "))
|
||||
line = strings.TrimSpace(aiListMarker.ReplaceAllString(strings.TrimSpace(line), ""))
|
||||
if line != "" {
|
||||
lines = append(lines, line)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user