Files
kefu/im/backend/internal/app/ai_agent.go
T
Your NameandClaude Opus 5 812d8032e1 修复 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>
2026-09-04 10:50:25 +08:00

482 lines
16 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package app
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"log"
"math/rand"
"regexp"
"strings"
"sync"
"time"
)
const (
aiHistoryMessages = 20
aiReplyMaxRunes = 120
aiJobMaxAttempts = 2
// A burst of incoming messages collapses into one reply, but the reader
// should not wait longer than this for it.
aiReplyMaxDelaySeconds = 15
)
type aiAgentBinding struct {
UserID int64
ModelID int64
Persona string
DailyReplyLimit int
Nickname string
Age int
City string
Bio string
}
type aiReplyJob struct {
ID int64
ConversationID int64
AgentUserID int64
Attempts int
}
// Called right after a message is committed, from every send path. It must stay
// cheap and must never fail the send: a missing reply is better than a failed
// message.
// aiGeneratedKey marks the context in which the worker writes its own reply.
// The loop it prevents is "a reply triggers another reply", which is a property
// of the message, not of the account: a person operating a managed test account
// is having a real conversation and deserves an answer.
type aiGeneratedKey struct{}
func markAIGenerated(ctx context.Context) context.Context {
return context.WithValue(ctx, aiGeneratedKey{}, true)
}
func isAIGenerated(ctx context.Context) bool {
value, _ := ctx.Value(aiGeneratedKey{}).(bool)
return value
}
func (a *App) enqueueAIReply(ctx context.Context, conversationID, senderID int64, members []int64, messageID int64) {
if !a.configBool(ctx, "ai.enabled", false) {
return
}
if isAIGenerated(ctx) {
return // The worker's own reply must never trigger the next one.
}
peerID := int64(0)
for _, member := range members {
if member != senderID {
if peerID != 0 {
return // Group conversations have no single managed recipient.
}
peerID = member
}
}
if peerID == 0 {
return
}
var agentUserID int64
var testBatch string
// Note there is no condition on the sender: whoever typed this, the reply is
// owed by the recipient. Runaway agent-to-agent chatter is prevented above,
// where the worker's own writes are recognised and skipped, and bounded by
// the per-agent daily reply limits.
err := a.db.QueryRowContext(ctx, `SELECT g.user_id,u.test_batch
FROM ai_agents g JOIN users u ON u.id=g.user_id
WHERE g.user_id=? AND g.enabled=1 AND u.is_test=1 AND u.status=1 AND u.deleted_at IS NULL`,
peerID).Scan(&agentUserID, &testBatch)
if err != nil || agentUserID == 0 {
return
}
if !aiBatchAllowed(a.configPlain(ctx, "ai.allow_batches", ""), testBatch) {
return
}
delay := a.aiReplyDelay(ctx)
if _, err = a.db.ExecContext(ctx, `INSERT INTO ai_reply_jobs(conversation_id,agent_user_id,trigger_message_id,run_after)
VALUES(?,?,?,DATE_ADD(NOW(3),INTERVAL ? MICROSECOND))
ON DUPLICATE KEY UPDATE trigger_message_id=VALUES(trigger_message_id),
run_after=LEAST(DATE_ADD(created_at,INTERVAL ? SECOND),VALUES(run_after))`,
conversationID, agentUserID, messageID, delay.Microseconds(), aiReplyMaxDelaySeconds); err != nil {
log.Printf("ai: 入队失败 conversation=%d: %v", conversationID, err)
}
}
// An empty allow list means every test account; otherwise the account's batch
// has to be named explicitly.
func aiBatchAllowed(allowed, batch string) bool {
allowed = strings.TrimSpace(allowed)
if allowed == "" {
return true
}
for _, item := range strings.Split(allowed, ",") {
if strings.TrimSpace(item) == strings.TrimSpace(batch) && batch != "" {
return true
}
}
return false
}
func (a *App) aiReplyDelay(ctx context.Context) time.Duration {
minDelay := a.configInt(ctx, "ai.reply_delay_min_ms", 2000)
maxDelay := a.configInt(ctx, "ai.reply_delay_max_ms", 6000)
if minDelay < 0 {
minDelay = 0
}
if maxDelay <= minDelay {
maxDelay = minDelay
}
spread := maxDelay - minDelay
if spread > 0 {
minDelay += rand.Intn(spread)
}
return time.Duration(minDelay) * time.Millisecond
}
// The worker follows the same ticker shape as processDueAccountClosures.
func (a *App) runAIReplyWorker(ctx context.Context) {
ticker := time.NewTicker(time.Second)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
a.processDueAIReplies(ctx)
}
}
}
func (a *App) processDueAIReplies(ctx context.Context) {
if !a.configBool(ctx, "ai.enabled", false) {
return
}
concurrency := a.configInt(ctx, "ai.max_concurrency", 4)
if concurrency < 1 || concurrency > 64 {
concurrency = 4
}
jobs, err := a.claimAIReplyJobs(ctx, concurrency)
if err != nil || len(jobs) == 0 {
return
}
var wait sync.WaitGroup
for _, job := range jobs {
wait.Add(1)
go func(job aiReplyJob) {
defer wait.Done()
if replyErr := a.processAIReplyJob(ctx, job); replyErr != nil {
a.failAIReplyJob(ctx, job, replyErr)
return
}
_, _ = a.db.ExecContext(ctx, `UPDATE ai_reply_jobs SET status=2,last_error='' WHERE id=?`, job.ID)
}(job)
}
wait.Wait()
}
// Claiming stamps a token with a single UPDATE and reads the rows back: each
// row can only leave status 0 once, so two instances never take the same job.
// SELECT ... FOR UPDATE SKIP LOCKED would be the obvious alternative, but it
// does not parse on MySQL 5.7, which some deployments still run.
func (a *App) claimAIReplyJobs(ctx context.Context, limit int) ([]aiReplyJob, error) {
token := randomToken()[:32]
result, err := a.db.ExecContext(ctx, `UPDATE ai_reply_jobs SET status=1,attempts=attempts+1,claim_token=?
WHERE status=0 AND run_after<=NOW(3) ORDER BY run_after LIMIT ?`, token, limit)
if err != nil {
return nil, err
}
if affected, _ := result.RowsAffected(); affected == 0 {
return nil, nil
}
rows, err := a.db.QueryContext(ctx, `SELECT id,conversation_id,agent_user_id,attempts FROM ai_reply_jobs WHERE claim_token=? AND status=1`, token)
if err != nil {
return nil, err
}
defer rows.Close()
jobs := []aiReplyJob{}
for rows.Next() {
var job aiReplyJob
if rows.Scan(&job.ID, &job.ConversationID, &job.AgentUserID, &job.Attempts) == nil {
jobs = append(jobs, job)
}
}
return jobs, rows.Err()
}
func (a *App) failAIReplyJob(ctx context.Context, job aiReplyJob, cause error) {
message := cause.Error()
if len([]rune(message)) > 500 {
message = string([]rune(message)[:500])
}
if job.Attempts >= aiJobMaxAttempts {
_, _ = a.db.ExecContext(ctx, `UPDATE ai_reply_jobs SET status=3,last_error=? WHERE id=?`, message, job.ID)
return
}
// Returning the job to the queue can collide with a newer pending job for
// the same conversation; that newer job supersedes this one.
if _, err := a.db.ExecContext(ctx, `UPDATE ai_reply_jobs SET status=0,claim_token='',last_error=?,run_after=DATE_ADD(NOW(3),INTERVAL 5 SECOND) WHERE id=?`, message, job.ID); err != nil {
_, _ = a.db.ExecContext(ctx, `UPDATE ai_reply_jobs SET status=3,last_error=? WHERE id=?`, message, job.ID)
}
}
func (a *App) processAIReplyJob(ctx context.Context, job aiReplyJob) error {
binding, err := a.loadAIAgent(ctx, job.AgentUserID)
if err != nil {
return err
}
if err = a.checkAIQuota(ctx, binding); err != nil {
return err
}
history, lastSenderID, err := a.aiConversationHistory(ctx, job.ConversationID, job.AgentUserID)
if err != nil {
return err
}
if len(history) == 0 || lastSenderID == job.AgentUserID {
// Someone already answered, or there is nothing to answer.
return nil
}
model, err := a.aiModelForAgent(ctx, binding)
if err != nil {
return err
}
provider, err := newAIProvider(model, a.config.Environment == "production")
if err != nil {
return err
}
first, err := a.aiFirstReply(ctx, job.ConversationID, job.AgentUserID)
if err != nil {
return err
}
request := aiChatRequest{
System: aiSystemPrompt(binding, a.configBool(ctx, "ai.disclose_in_chat", true)),
Messages: history,
}
callCtx, cancel := context.WithTimeout(ctx, time.Duration(model.TimeoutMS+2000)*time.Millisecond)
defer cancel()
started := time.Now()
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) {
text = "[AI 助手] " + text
}
if err = a.sendAIReply(ctx, job, text); err != nil {
return err
}
_, _ = a.db.ExecContext(ctx, `UPDATE ai_agents SET reply_count=reply_count+1,last_reply_at=NOW(3) WHERE user_id=?`, job.AgentUserID)
return nil
}
func (a *App) sendAIReply(ctx context.Context, job aiReplyJob, text string) error {
item, members, err := a.persistMessageContext(markAIGenerated(ctx), job.ConversationID, job.AgentUserID,
"ai-"+randomToken()[:20], 1, map[string]any{"text": text})
if err != nil {
return err
}
// The reply reaches clients through the same push the sender's own messages
// use, so unread counts and conversation ordering need no special casing.
a.hub.broadcast(members, map[string]any{"command": "MESSAGE_PUSH", "data": item})
return nil
}
func (a *App) loadAIAgent(ctx context.Context, userID int64) (aiAgentBinding, error) {
binding := aiAgentBinding{UserID: userID}
var birthday sql.NullTime
err := a.db.QueryRowContext(ctx, `SELECT g.model_id,g.persona,g.daily_reply_limit,p.nickname,p.birthday,p.city_name,p.bio
FROM ai_agents g JOIN user_profiles p ON p.user_id=g.user_id JOIN users u ON u.id=g.user_id
WHERE g.user_id=? AND g.enabled=1 AND u.is_test=1 AND u.status=1 AND u.deleted_at IS NULL`, userID).
Scan(&binding.ModelID, &binding.Persona, &binding.DailyReplyLimit, &binding.Nickname, &birthday, &binding.City, &binding.Bio)
if errors.Is(err, sql.ErrNoRows) {
return binding, fmt.Errorf("账号未开启 AI 托管")
}
if birthday.Valid {
binding.Age = age(birthday.Time)
}
return binding, err
}
func (a *App) aiModelForAgent(ctx context.Context, binding aiAgentBinding) (aiModel, error) {
if binding.ModelID > 0 {
model, err := a.loadAIModel(ctx, binding.ModelID)
if err == nil && model.Status == 1 {
return model, nil
}
}
return a.defaultAIModel(ctx)
}
// Quotas are counted from the call log so a restart cannot reset them.
func (a *App) checkAIQuota(ctx context.Context, binding aiAgentBinding) error {
if limit := a.configInt(ctx, "ai.daily_call_limit", 0); limit > 0 {
var used int
_ = a.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM ai_call_logs WHERE scene='chat' AND created_at>=CURRENT_DATE()`).Scan(&used)
if used >= limit {
return fmt.Errorf("已达到今日全局调用上限 %d", limit)
}
}
limit := binding.DailyReplyLimit
if limit <= 0 {
limit = a.configInt(ctx, "ai.agent_daily_reply_limit", 0)
}
if limit > 0 {
var used int
_ = a.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM ai_call_logs WHERE scene='chat' AND agent_user_id=? AND status=1 AND created_at>=CURRENT_DATE()`, binding.UserID).Scan(&used)
if used >= limit {
return fmt.Errorf("账号已达到今日回复上限 %d", limit)
}
}
return nil
}
func (a *App) aiFirstReply(ctx context.Context, conversationID, agentUserID int64) (bool, error) {
var replied int
err := a.db.QueryRowContext(ctx, `SELECT EXISTS(SELECT 1 FROM im_messages WHERE conversation_id=? AND sender_id=?)`, conversationID, agentUserID).Scan(&replied)
return replied == 0, err
}
// Only the tail of the conversation is sent: enough for continuity, bounded in
// cost. Media turns become placeholders because the model gets text only.
func (a *App) aiConversationHistory(ctx context.Context, conversationID, agentUserID int64) ([]aiChatMessage, int64, error) {
rows, err := a.db.QueryContext(ctx, `SELECT sender_id,message_type,body,recalled_at,admin_removed_at
FROM im_messages WHERE conversation_id=? ORDER BY seq DESC LIMIT ?`, conversationID, aiHistoryMessages)
if err != nil {
return nil, 0, err
}
defer rows.Close()
ordered := []aiChatMessage{}
lastSenderID := int64(0)
for rows.Next() {
var senderID int64
var messageType int
var body []byte
var recalledAt, removedAt sql.NullTime
if rows.Scan(&senderID, &messageType, &body, &recalledAt, &removedAt) != nil {
continue
}
if lastSenderID == 0 {
lastSenderID = senderID
}
if recalledAt.Valid || removedAt.Valid {
continue
}
text := aiMessageText(messageType, body)
if text == "" {
continue
}
role := "user"
if senderID == agentUserID {
role = "assistant"
}
ordered = append([]aiChatMessage{{Role: role, Text: text}}, ordered...)
}
return ordered, lastSenderID, rows.Err()
}
func aiMessageText(messageType int, body []byte) string {
switch messageType {
case 2:
return "[图片]"
case 3:
return "[语音]"
}
var content struct {
Text string `json:"text"`
}
if json.Unmarshal(body, &content) != nil {
return ""
}
return strings.TrimSpace(content.Text)
}
func aiSystemPrompt(binding aiAgentBinding, disclose bool) string {
persona := strings.TrimSpace(binding.Persona)
if persona == "" {
// Falling back to the profile keeps the replies consistent with what the
// other person can see on the page.
details := []string{}
if binding.Nickname != "" {
details = append(details, "昵称"+binding.Nickname)
}
if binding.Age > 0 {
details = append(details, fmt.Sprintf("%d 岁", binding.Age))
}
if binding.City != "" {
details = append(details, "在"+binding.City)
}
if bio := strings.TrimSpace(binding.Bio); bio != "" {
details = append(details, "个人简介:"+bio)
}
persona = "你是一位社交软件用户," + strings.Join(details, "") + "。"
}
rules := []string{
"用中文口语回复,像真人聊天一样简短自然,不超过 40 个字。",
"一次只说一件事,不要分点、不要 Markdown、不要堆叠表情。",
"不索取或提供手机号、微信等联系方式,不谈金钱、转账、投资。",
"不主动约线下见面,不输出链接。",
"不知道的事就说不知道,不要编造经历细节。",
}
if disclose {
rules = append(rules, "如果对方询问你是不是机器人或 AI,必须如实说明你是 AI 助手。")
}
return persona + "\n\n对话要求:\n- " + strings.Join(rules, "\n- ")
}
// 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 == "" {
return ""
}
if index := strings.Index(text, "```"); index >= 0 {
text = text[:index]
}
replacer := strings.NewReplacer("**", "", "__", "", "##", "", "> ", "", "\r", "\n")
text = replacer.Replace(text)
lines := []string{}
for _, line := range strings.Split(text, "\n") {
line = strings.TrimSpace(aiListMarker.ReplaceAllString(strings.TrimSpace(line), ""))
if line != "" {
lines = append(lines, line)
}
}
text = strings.TrimSpace(strings.Join(lines, " "))
if runes := []rune(text); len(runes) > aiReplyMaxRunes {
text = strings.TrimSpace(string(runes[:aiReplyMaxRunes]))
}
return text
}