按协议而不是按厂商做适配,与现有短信、对象存储的做法一致: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>
140 lines
13 KiB
Markdown
140 lines
13 KiB
Markdown
# 测试账号 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/completions`,`Authorization: Bearer`,system 作为首条 message |
|
||
| `anthropic` | Claude | `POST {base_url}/v1/messages`,`x-api-key` + `anthropic-version`,system 是顶层字段 |
|
||
| `gemini` | Google Gemini | `POST {base_url}/v1beta/models/{model}:generateContent`,`contents[].parts[].text`,assistant 角色写作 `model`;密钥用 `x-goog-api-key` 头而不是查询参数,避免写进代理日志 |
|
||
| `webhook` | Coze、Dify、n8n、自建编排 | 后端 POST 规范化 JSON,期望返回 `{"text": "..."}`,不为每个编排平台写代码 |
|
||
| `debug` | 开发与测试 | 不出网,返回确定性文案,供本地联调和单元测试使用 |
|
||
|
||
统一接口(`internal/app/ai_providers.go`):
|
||
|
||
```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.sql`(P0,已落地):
|
||
|
||
- `ai_models`:`id / name / protocol / base_url / model_name / api_key_cipher / temperature / max_tokens / timeout_ms / status / is_default / extra_json`。密钥复用 `integration.go` 的 `encryptSecret`/`decryptSecret`(AES-GCM),与 `system_configs` 的 secret 字段同一套密钥。允许同时存在多条不同协议的记录,`is_default` 在写入时互斥。
|
||
- `ai_call_logs`:`model_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_agents`:`user_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_jobs`:`id / conversation_id / agent_user_id / trigger_message_id / status(0待处理 1处理中 2成功 3失败) / attempts / run_after / claim_token / last_error`。同一会话同时只允许一个待回复任务:`pending_key` 是 `IF(status=0, conversation_id, NULL)` 的 STORED 生成列,加唯一键后,待处理状态天然互斥,离开该状态即为 NULL 不再约束。
|
||
|
||
全局开关放进 `integrationSpecs` 新增的 `ai` 组(`system_configs` 表),后台现成的通用面板即可渲染:`ai.enabled`、`ai.default_model_id`、`ai.max_concurrency`、`ai.daily_call_limit`、`ai.reply_delay_min/max_ms`、`ai.allow_batches`(限定 `test_batch`)、`ai.disclose_in_chat`、`ai.log_retention_days`、`ai.fallback_text`。
|
||
|
||
## 托管流程
|
||
|
||
1. `persistMessage` 成功写库并广播后,判断该会话对端是否为已启用的 AI 托管测试账号;是则写入 `ai_reply_jobs`(`run_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/models`、`POST /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.go` 的 `Run()` 增加 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.go`:`aiProvider` 接口与五个协议适配器,含地址校验(禁止账号、查询参数、片段,生产环境强制 HTTPS)和 512 KiB 响应上限。
|
||
- `internal/app/admin_ai.go`:模型 CRUD、探活、调用日志写入、`validateAIConfig`。
|
||
- `internal/app/integration.go`:`ai` 配置分组与 `POST /admin/v1/integrations/ai/test`(用默认模型真实调用一次)。
|
||
- 管理端:`ai-config.vue`(复用通用配置面板)、`ai-models.vue`(模型表格、编辑弹窗、测试按钮)、两条路由。
|
||
|
||
验证方式:`internal/app/ai_providers_test.go` 用 `httptest` 覆盖四种线协议的请求组装与响应解析(system 提示词位置、鉴权头、assistant→model 角色映射、错误体与非 JSON 响应、密钥不进查询参数),以及地址校验、工厂守卫和 debug 协议的确定性。本地起服务后走通了「登录→建模型→探活→改配置→连通性测试→删除」,`ai_call_logs` 与 `admin_audit_logs` 均有对应记录。
|
||
|
||
P0 不改动任何既有业务路径:`persistMessage` 尚未重构,IM 流程没有接入点,总开关默认关闭。
|
||
|
||
## P1 落地记录
|
||
|
||
新增 `migrations/031_ai_agents.sql`(`ai_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=2`、`ai_call_logs` 有 `scene='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=999`、`scene=hack`)返回 400。
|
||
|
||
一处实现修正:`AVG(latency_ms)` 在 MySQL 里返回 DECIMAL,扫进 `sql.NullInt64` 会失败——按模型汇总因此每行都被静默跳过、今日平均耗时恒为 0。已改为 `sql.NullFloat64` 后取整。 |