修复 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
@@ -36,12 +36,21 @@ type aiChatRequest struct {
|
||||
|
||||
type aiChatResult struct {
|
||||
Text string
|
||||
Reasoning string
|
||||
PromptTokens int
|
||||
CompletionTokens int
|
||||
FinishReason string
|
||||
RequestID string
|
||||
}
|
||||
|
||||
// The model ran out of budget mid-thought. For a reasoning model that usually
|
||||
// means the whole ceiling went on the chain of thought and Text is empty; for
|
||||
// any model it can also mean the answer itself was cut off. Both deserve one
|
||||
// retry with room to finish rather than a blank or half-finished message.
|
||||
func (r aiChatResult) truncated() bool {
|
||||
return r.FinishReason == "length" || (r.Text == "" && r.Reasoning != "")
|
||||
}
|
||||
|
||||
type aiProvider interface {
|
||||
Chat(ctx context.Context, req aiChatRequest) (aiChatResult, error)
|
||||
}
|
||||
@@ -160,6 +169,20 @@ func aiRequestMaxTokens(req aiChatRequest, model aiModel) int {
|
||||
return 256
|
||||
}
|
||||
|
||||
// One retry, with enough room for the thinking plus a short answer, and a cap
|
||||
// so a misconfigured model cannot bill an unbounded completion.
|
||||
func aiRetryMaxTokens(current int) int {
|
||||
const floor, ceiling = 1024, 4096
|
||||
raised := current * 4
|
||||
if raised < floor {
|
||||
raised = floor
|
||||
}
|
||||
if raised > ceiling {
|
||||
raised = ceiling
|
||||
}
|
||||
return raised
|
||||
}
|
||||
|
||||
func aiPostJSON(ctx context.Context, client *http.Client, endpoint string, headers map[string]string, payload any) ([]byte, error) {
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
@@ -234,6 +257,11 @@ func (p openAIProvider) Chat(ctx context.Context, req aiChatRequest) (aiChatResu
|
||||
Choices []struct {
|
||||
Message struct {
|
||||
Content string `json:"content"`
|
||||
// Reasoning models put their chain of thought here and the answer
|
||||
// in Content. Spending the whole budget on the former leaves the
|
||||
// latter empty, which is indistinguishable from a broken model
|
||||
// unless this field is read.
|
||||
ReasoningContent string `json:"reasoning_content"`
|
||||
} `json:"message"`
|
||||
FinishReason string `json:"finish_reason"`
|
||||
} `json:"choices"`
|
||||
@@ -256,6 +284,7 @@ func (p openAIProvider) Chat(ctx context.Context, req aiChatRequest) (aiChatResu
|
||||
}
|
||||
return aiChatResult{
|
||||
Text: strings.TrimSpace(payload.Choices[0].Message.Content),
|
||||
Reasoning: strings.TrimSpace(payload.Choices[0].Message.ReasoningContent),
|
||||
PromptTokens: payload.Usage.PromptTokens,
|
||||
CompletionTokens: payload.Usage.CompletionTokens,
|
||||
FinishReason: payload.Choices[0].FinishReason,
|
||||
|
||||
Reference in New Issue
Block a user