Files
kefu/im/backend/docs/ai-agent.md
T
Your NameandClaude Opus 5 a024d59827 后端接入 AI 模型并支持测试账号托管回复
按协议而不是按厂商做适配,与现有短信、对象存储的做法一致:openai 兼容格式
一个适配器即可覆盖 DeepSeek、通义、智谱、火山方舟、Ollama 等,新增厂商通常
只需在后台加一条模型记录;anthropic、gemini、webhook、debug 各一个适配器。
密钥沿用 integration.go 的 AES-GCM 加密存储。

测试账号托管的回复走 persistMessage 同一条落库和推送路径,因此未读数、
WebSocket 推送和会话排序全部复用现有逻辑,客户端无需改动。为此把
persistMessage 抽出 persistMessageContext,因为 worker 没有 *http.Request。

任务队列用数据库表而非内存:重启不丢回复,多实例不重复消费。领取用 UPDATE
打 claim_token 再回读,没有用 SELECT ... FOR UPDATE SKIP LOCKED——生产存在
MySQL 5.7 环境,那里该语法无法解析。同一会话同时只允许一个待处理任务
(pending_key 生成列 + 唯一键),所以用户连发多条消息只会得到一条回复。

闸门:仅 is_test=1 且在允许批次内的账号生效,托管账号之间不互相触发,三层
配额,调用失败默认静默。会话内是否标注 AI 身份由 ai.disclose_in_chat 控制
并默认开启,关闭前需确认所在地区的监管要求。

app.go、integration.go、im.go 三个文件同时包含本次改动之前工作区里就已存在
的未提交修改,一并带入。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 08:31:04 +08:00

13 KiB
Raw Blame History

测试账号 AI 托管

测试账号(users.is_test=1)在收到真实用户的私信后,由后台配置的大模型生成回复并以该账号身份发出。目标是让新用户在冷启动阶段能得到真实的对话反馈,同时把模型接入做成后台可配置、可多协议共存的能力。

本文只描述设计与实施边界,未实现的部分标注为阶段目标。

协议抽象

模型厂商众多但线上协议只有少数几种,因此按协议而不是按厂商建适配器,与现有短信(sms_providers.go)和对象存储(storage_providers.go)的做法一致:

协议 覆盖范围 关键差异
openai OpenAI、DeepSeek、通义千问兼容模式、智谱、Moonshot、火山方舟、SiliconFlow、Ollama、vLLM、one-api/new-api 网关 POST {base_url}/chat/completionsAuthorization: Bearersystem 作为首条 message
anthropic Claude POST {base_url}/v1/messagesx-api-key + anthropic-versionsystem 是顶层字段
gemini Google Gemini POST {base_url}/v1beta/models/{model}:generateContentcontents[].parts[].textassistant 角色写作 model;密钥用 x-goog-api-key 头而不是查询参数,避免写进代理日志
webhook Coze、Dify、n8n、自建编排 后端 POST 规范化 JSON,期望返回 {"text": "..."},不为每个编排平台写代码
debug 开发与测试 不出网,返回确定性文案,供本地联调和单元测试使用

统一接口(internal/app/ai_providers.go):

type aiChatMessage struct{ Role, Text string } // role: user | assistant
type aiChatRequest struct {
    System      string
    Messages    []aiChatMessage
    MaxTokens   int
    Temperature float64
}
type aiChatResult struct {
    Text                           string
    PromptTokens, CompletionTokens int
    FinishReason, RequestID        string
}
type aiProvider interface {
    Chat(ctx context.Context, req aiChatRequest) (aiChatResult, error)
}

新增一家厂商时,若走 OpenAI 兼容格式则只需在后台新增一条模型记录,不改代码。

数据模型

migrations/030_ai_models.sqlP0,已落地):

  • ai_modelsid / name / protocol / base_url / model_name / api_key_cipher / temperature / max_tokens / timeout_ms / status / is_default / extra_json。密钥复用 integration.goencryptSecret/decryptSecretAES-GCM),与 system_configs 的 secret 字段同一套密钥。允许同时存在多条不同协议的记录,is_default 在写入时互斥。
  • ai_call_logsmodel_id / scene / agent_user_id / conversation_id / latency_ms / prompt_tokens / completion_tokens / status / error / prompt_digest / reply_text,用于成本核算和事后审核,按 ai.log_retention_days 定期清理。后台探活也会写入(scene='probe')。

P1 迁移新增:

  • ai_agentsuser_id(唯一,必须 is_test=1/ model_id / persona(系统提示词)/ enabled / daily_reply_limit / reply_delay_min_ms / reply_delay_max_ms。未单独配置 persona 时由 user_profiles 的昵称、年龄、城市、bio、标签拼装,保证人设和资料一致。
  • ai_reply_jobsid / conversation_id / agent_user_id / trigger_message_id / status(0待处理 1处理中 2成功 3失败) / attempts / run_after / claim_token / last_error。同一会话同时只允许一个待回复任务:pending_keyIF(status=0, conversation_id, NULL) 的 STORED 生成列,加唯一键后,待处理状态天然互斥,离开该状态即为 NULL 不再约束。

全局开关放进 integrationSpecs 新增的 ai 组(system_configs 表),后台现成的通用面板即可渲染:ai.enabledai.default_model_idai.max_concurrencyai.daily_call_limitai.reply_delay_min/max_msai.allow_batches(限定 test_batch)、ai.disclose_in_chatai.log_retention_daysai.fallback_text

托管流程

  1. persistMessage 成功写库并广播后,判断该会话对端是否为已启用的 AI 托管测试账号;是则写入 ai_reply_jobsrun_after = now + rand(delay_min, delay_max))。判定和入队都不阻塞发送方的响应。
  2. 同一会话已有待处理任务时不新建,只把 run_after 后移(上限 15 秒)并更新 trigger_message_id。用户连发三条会得到一条回复,而不是三条。
  3. Run() 中新增 worker goroutine(与现有 processDueAccountClosures 同样的 ticker 模式),每秒领取到期任务。领取方式是一条 UPDATE ... SET status=1,claim_token=? WHERE status=0 AND run_after<=NOW(3) ORDER BY run_after LIMIT n 再按 token 回读:每行只能离开待处理状态一次,多实例不会重复消费。没有用 SELECT ... FOR UPDATE SKIP LOCKED——生产 compose 是 MySQL 8.4,但开发环境仍在 5.7,那里该语法直接解析失败。
  4. 组装上下文:取该会话最近 20 条消息,对端消息映射为 user、托管账号自己的消息映射为 assistant;图片和语音降级为 [图片] [语音] 占位符。系统提示词 = persona + 硬性约束(中文、口语化、不超过 40 字、不加 emoji 堆砌、不索取联系方式、不涉及金钱和线下见面、不输出链接)。
  5. 调用模型(超时取 ai_models.timeout_ms,失败重试一次并退避)。成功后清洗输出(去 Markdown、截断、过滤空回复),走 persistMessageContext 以托管账号身份发送。
  6. 回复经过与真人完全相同的落库和推送路径,因此未读数、WebSocket 推送、离线推送、会话列表排序全部复用现有逻辑,客户端零改动

闸门

  • ai.enabled 为总开关;生产环境禁止使用 debug 协议(与 validateSMSProviderConfig 中"生产环境禁止 debug 短信提供商"同一规则)。
  • 仅对 is_test=1 且在 ai.allow_batches 内的账号生效;AI 账号之间不互相触发,避免自激对话。
  • 三层配额:单账号每日回复上限、全局每日调用上限、并发信号量。任一超限即丢弃任务并记日志,不做排队堆积。
  • 发送前复查会话最后一条消息,若已是托管账号自己发的则跳过,避免重复回复。
  • persistMessage 已有的禁言检查(isSanctionActive)和媒体归属校验对 AI 同样生效。发送频控要用独立限流键,避免托管账号触发按人设计的"每日活跃聊天上限"(dailyActiveChatLimitError)。
  • 模型调用失败时默认静默,不发兜底话术;ai.fallback_text 非空时才发送,避免机械感。

后台

  • GET/POST/PUT/DELETE /admin/v1/ai/modelsPOST /admin/v1/ai/models/:id/test(发一条探活提示词,返回延迟、tokens 和实际回复)。
  • GET/PUT /admin/v1/ai/agents:列出测试账号及其托管状态,支持按批次批量开关和批量绑定模型。
  • GET /admin/v1/ai/logs:调用日志,可按模型、场景、结果和时间范围筛选。
  • GET /admin/v1/ai/usage:今日用量、近 7 天按日与按模型汇总、任务队列状态、最近错误,以及服务端计算的告警列表。
  • GET/PUT /admin/v1/integrations/ai:全局开关,复用现有通用配置面板。

权限沿用 permit("system:manage", ...);模型和托管开关的变更走 a.audit(...) 落审计日志。管理端前端只需新增一个 ai-config.vue(复用 IntegrationConfigPanel)、一个模型列表页,并把 socialApi.integration 的 group 联合类型加上 'ai'

需要改动的既有代码

  • persistMessage(r *http.Request, ...) 抽出 persistMessageContext(ctx context.Context, ...),HTTP 处理器保留薄封装。worker 没有 *http.Request,这是唯一侵入式改造。
  • app.goRun() 增加 worker goroutine 和退出时的优雅停止。
  • integrationSpecs 增加 ai 组,adminTestIntegration 增加 ai 分支。

分期

  1. P0 骨架(已完成):迁移、ai_models CRUD、五种协议适配器、后台配置页与探活按钮。不接 IM,可独立验证。
  2. P1 托管闭环(已完成)ai_agents 绑定、任务表与 worker、persistMessageContext 重构、延迟与合并。默认 ai.enabled=false 发布。
  3. P2 补齐(已完成):调用日志页、用量汇总与告警、日志与任务清理。
  4. P3 可选:AI 主动打招呼、动态与评论生成、多模型灰度路由。

风险与待定

  • 合规:测试账号资料页已标注"AI生成虚拟资料",但会话中是否显式标注由 ai.disclose_in_chat 控制,默认开启。对真实用户隐瞒机器人身份在部分地区属于监管红线,关闭前需要产品确认。
  • 成本:单条回复约 500~1500 tokens。上线前应先用 debug 协议跑通链路,再用真实模型小批量放量,靠 ai_call_logs 观察日用量。
  • 数据留存ai_call_logs.reply_text 会保存对话内容,属于用户数据。默认只存托管账号自己的回复和用户消息摘要(prompt_digest),完整 prompt 不落库。

P0 落地记录

已实现的文件:

  • migrations/030_ai_models.sql:两张表和 11 个 ai.* 配置项。
  • internal/app/ai_providers.goaiProvider 接口与五个协议适配器,含地址校验(禁止账号、查询参数、片段,生产环境强制 HTTPS)和 512 KiB 响应上限。
  • internal/app/admin_ai.go:模型 CRUD、探活、调用日志写入、validateAIConfig
  • internal/app/integration.goai 配置分组与 POST /admin/v1/integrations/ai/test(用默认模型真实调用一次)。
  • 管理端:ai-config.vue(复用通用配置面板)、ai-models.vue(模型表格、编辑弹窗、测试按钮)、两条路由。

验证方式:internal/app/ai_providers_test.gohttptest 覆盖四种线协议的请求组装与响应解析(system 提示词位置、鉴权头、assistant→model 角色映射、错误体与非 JSON 响应、密钥不进查询参数),以及地址校验、工厂守卫和 debug 协议的确定性。本地起服务后走通了「登录→建模型→探活→改配置→连通性测试→删除」,ai_call_logsadmin_audit_logs 均有对应记录。

P0 不改动任何既有业务路径:persistMessage 尚未重构,IM 流程没有接入点,总开关默认关闭。

P1 落地记录

新增 migrations/031_ai_agents.sqlai_agents + ai_reply_jobs)、internal/app/ai_agent.go(入队、worker、上下文组装、提示词、清洗、配额)、internal/app/admin_ai_agents.go(账号绑定与任务查询),并把 persistMessage 抽成 persistMessageContext。管理端新增 ai-agents.vue

真库联调结果(本地 MySQL 5.7 + debug 协议):

  • 真实用户连发三条消息 → 只入队一个任务trigger_message_id 指向最后一条)→ 托管账号回一条,ai_reply_jobs.status=2ai_call_logsscene='chat' 记录、ai_agents.reply_count 递增。
  • 新会话里托管账号的第一条回复带 [AI 助手] 前缀(ai.disclose_in_chat 控制)。
  • 托管账号之间互发消息不入队;关闭绑定后不再入队。

一处设计修正:原计划用 SELECT ... FOR UPDATE SKIP LOCKED 领取任务,实测开发环境的 MySQL 5.7.26 直接报 ERROR 1064 ... near 'SKIP LOCKED',已改为 claim_token 方案,5.7 与 8.x 都可用。

尚未接入:P2 的调用日志页、配额告警和日志清理任务;ai.fallback_text 已实现但默认留空(失败静默)。

P2 落地记录

新增 internal/app/admin_ai_logs.go(日志查询、用量汇总、告警计算、清理任务)与管理端 ai-logs.vue

告警在服务端计算(aiUsageAlerts),前端只负责显示,这样以后接入通知渠道时判定逻辑只有一份。触发条件:总开关已开但无可用模型(error)、没有任何账号开启托管(warning)、今日调用达到或超过配额 80%(warning/error)、当日调用 ≥5 次且失败率 >30%(error,附最近一条错误)、有任务超过 60 秒未处理即 worker 可能没在跑(warning)。总开关关闭时不产生任何告警。

清理runAIMaintenance 每小时执行一次,进程启动时也先跑一遍:按 ai.log_retention_days 删除过期调用日志、删除 7 天前的已完成/失败任务、把卡在「处理中」超过 5 分钟的任务标记为失败(进程在回复中途被杀时不会永久占用会话的待处理名额)。删除都按 1000 条一批,避免长事务。

实测确认:保留 30 天时,40 天和 31 天前的日志被清理、29 天前的保留;8 天前的已完成任务被清理、2 天前的失败任务保留;卡住 10 分钟的任务被标记为「处理超时,任务已中断」。用量接口在造出 7 次调用(含 1 次失败、耗时非零)后返回正确的今日汇总、按模型汇总和最近错误;非法筛选参数(days=999scene=hack)返回 400。

一处实现修正:AVG(latency_ms) 在 MySQL 里返回 DECIMAL,扫进 sql.NullInt64 会失败——按模型汇总因此每行都被静默跳过、今日平均耗时恒为 0。已改为 sql.NullFloat64 后取整。