refactor(ai): 助手按 AI-AGENT 开发指南对齐(Ask 模式)
CI / Backend (go vet + test) (push) Waiting to run
CI / Frontend (lint + build + test) (push) Waiting to run

对照指南逐条评估后落地 4 项真实差距(MCP/Agent Loop/白名单等重型章节不适用,明确排除):
- 上下文预算(指南 07 TrimMessages):历史按 24k 字符预算从最旧裁剪,system 标注
  「更早的对话已省略」;抽 trimHistory 独立函数+单测(超预算丢最旧/小历史不裁)
- 配额超限诚实终态(指南 05.3/06 禁止伪装 completed):不再 400 报错,返回提示
  文案;前端渲染为 warning 提示条样式,对话上下文保留
- 回复脱敏(指南 08):assistant 回复同样过 RedactTextOnly,堵住模型复述样本
  带回敏感串的通道
- 退避重试(指南 05.2):429/5xx/网络错误指数退避 1s→2s 最多 2 次(计入总超时),
  鉴权/参数错不重试
This commit is contained in:
opsx committed 2026-11-01 01:59:17 +08:00
1 parent 448f695c28
commit 550d8f4967
3 files changed
+106 -13

No files matched your search

+34
View File
@@ -0,0 +1,34 @@
package services
import (
"strings"
"testing"
)
// TestChatHistoryBudgetTrim 历史按字符预算裁剪:超预算时最旧被丢、标记注入。
// 通过构造超长历史走 RunAIChat 会因未启用而提前返回,这里直接测裁剪语义的独立函数化验证——
// 为可测性把裁剪抽成了 trimHistory。
func TestChatHistoryBudgetTrim(t *testing.T) {
long := strings.Repeat("a", 15000)
history := []ChatMessage{
{Role: "user", Content: long},
{Role: "assistant", Content: long},
{Role: "user", Content: "最后一句"},
}
kept, truncated := trimHistory(history, 24000)
if !truncated {
t.Fatal("超出预算应标记 truncated")
}
if len(kept) != 2 {
t.Fatalf("应保留最近 2 条(丢弃最旧的 15000 字条目),got %d", len(kept))
}
if kept[0].Content != long || kept[len(kept)-1].Content != "最后一句" {
t.Fatal("应保留最近的消息")
}
// 不超预算时全保留
small := []ChatMessage{{Role: "user", Content: "hi"}, {Role: "assistant", Content: "ok"}}
kept2, truncated2 := trimHistory(small, 24000)
if truncated2 || len(kept2) != 2 {
t.Fatalf("小历史不应裁剪: %v %d", truncated2, len(kept2))
}
}
+57 -3
View File
@@ -162,6 +162,37 @@ type ChatMessage struct {
Content string `json:"content" binding:"required"`
}
// trimHistory 按字符预算从最旧开始裁剪对话历史(指南 07 TrimMessages);
// 返回保留的历史与是否发生了裁剪(调用方在 system 里标注省略)
func trimHistory(history []ChatMessage, budget int) ([]ChatMessage, bool) {
var kept []ChatMessage
total := 0
truncated := false
for i := len(history) - 1; i >= 0; i-- {
size := len([]rune(history[i].Content))
if total+size > budget && len(kept) > 0 {
truncated = true
break
}
kept = append([]ChatMessage{{Role: history[i].Role, Content: history[i].Content}}, kept...)
total += size
}
return kept, truncated
}
// isRetryableAIError 网络抖动/限流/上游临时故障才值得重试;鉴权与参数错重试无意义
func isRetryableAIError(err error) bool {
if err == nil {
return false
}
msg := err.Error()
return strings.Contains(msg, "429") ||
strings.Contains(msg, "500") || strings.Contains(msg, "502") ||
strings.Contains(msg, "503") || strings.Contains(msg, "504") ||
strings.Contains(msg, "connection refused") || strings.Contains(msg, "context deadline exceeded") ||
strings.Contains(msg, "EOF") || strings.Contains(msg, "timeout")
}
// RunAIChat 全站 AI 助手浮窗:多轮对话 + 当前页面上下文注入(轻量信号摘要)。
// 与整页分析共享配置/配额/脱敏/Pro 闸门;context 里的 sourceId 可选,带了就注入日志摘要。
func RunAIChat(ctx context.Context, cfg models.AIConfig, userID uint, history []ChatMessage, page string, pageContext string) (string, error) {
@@ -169,7 +200,8 @@ func RunAIChat(ctx context.Context, cfg models.AIConfig, userID uint, history []
return "", fmt.Errorf("AI 未启用:请先在「智能运维 → 模型配置」中开启并保存")
}
if cfg.DailyTokenCap > 0 && AITokenUsedToday(userID) >= cfg.DailyTokenCap {
return "", fmt.Errorf("今日 AI 用量已达上限,明天再试或调整上限")
// 指南 05.3:超限不伪装成功 —— 结构化 notice 而非报错,对话上下文在前端保留
return "\u2014\u2014 今日 AI 配额已用完(" + fmt.Sprintf("%d tokens),明天再试或在「AI 模型接入」调整上限 \u2014\u2014", cfg.DailyTokenCap), nil
}
// 提示词设计:身份固定(不猜底层模型)、风格收敛、话题回引导、上下文数据
@@ -187,8 +219,13 @@ func RunAIChat(ctx context.Context, cfg models.AIConfig, userID uint, history []
}
system += "\n\n" + RedactTextOnly(trimmed, cfg.RedactExtra)
}
// 上下文预算(指南 07 TrimMessages):按字符预算从最旧开始丢弃,优先保留近期对话
trimmed, truncated := trimHistory(history, 24000)
if truncated {
system += "\n(更早的对话已省略)"
}
msgs := []aiMessage{{Role: "system", Content: system}}
for _, m := range history {
for _, m := range trimmed {
msgs = append(msgs, aiMessage{Role: m.Role, Content: m.Content})
}
// 脱敏:用户消息里的敏感串同样替换
@@ -197,10 +234,27 @@ func RunAIChat(ctx context.Context, cfg models.AIConfig, userID uint, history []
msgs[i].Content = RedactTextOnly(msgs[i].Content, cfg.RedactExtra)
}
}
reply, promptTokens, completionTokens, err := aiChat(ctx, cfg, msgs)
// 指南 05.2:429/5xx/网络错误指数退避重试(1s→2s,最多 2 次);鉴权/参数错不重试
var reply string
var promptTokens, completionTokens int
var err error
for attempt := 0; ; attempt++ {
reply, promptTokens, completionTokens, err = aiChat(ctx, cfg, msgs)
if err == nil || attempt >= 2 || !isRetryableAIError(err) {
break
}
backoff := time.Duration(1<<attempt) * time.Second
log.Printf("[ai-chat] 可重试错误(%v),%s 后第 %d 次重试", err, backoff, attempt+1)
select {
case <-time.After(backoff):
case <-ctx.Done():
return "", ctx.Err()
}
}
if err != nil {
return "", err
}
reply = RedactTextOnly(reply, cfg.RedactExtra)
// 计入每日配额:落一条 chat 类型用量记录
db := repository.GetDB()
if db != nil {