机器人回群、可观测性与部署
「今天 Agent 很慢」
这是上线后你会收到的第一条反馈,而且它不带任何可用信息。没有 trace 时,能做的只有猜:是检索慢了?重排慢了?模型首 token 慢了?数据库连接池耗尽了?还是那个用户的网络问题?
猜的代价很具体。团队通常会先给向量检索加缓存——听起来最像瓶颈。做完发现没用。
这条 trace 一眼给出答案:检索三路加起来 180ms,而 llm.answer 一个人占了 1.7 秒。想让它变快,要动的是输出长度和模型选择,不是向量索引。那 180ms 就算降到 0,用户也感觉不到。
这一篇的顺序是:先让系统可观测,再基于观测做限流和降级,最后部署。反过来做的话,你会在没有数据的情况下做优化决策。
Trace:span 划分与属性
OpenTelemetry 的 FastAPI 自动埋点能给你 HTTP 层的 span,但 Agent 的关键路径全在业务代码里,要手动划:
span 的粒度原则:能独立成为优化对象的操作各占一个 span。vector.search 和 fulltext.search 分开,因为它们的优化手段完全不同;llm.plan 和 llm.answer 分开,因为一个 是选工具(输出很短),一个是生成回答(输出很长),延迟特征差一个数量级。
每个 span 该带的属性:request_id、conversation_id、model、tokens_in、tokens_out。不带的:用户问题原文、文档正文、工具参数、Authorization 头。
脱敏要在采集侧做,不能在展示侧做。 采集时就不写进去,才叫脱敏;写进去之后在 UI 上隐藏,那些数据仍然在 trace 后端的存储里,仍然会进备份,仍然可能被任何有查询权限的人看到。
request_id 要一路透传到前端,让用户报障时能直接给出它。「大概下午三点多」和 req_8f3a2c 之间的排查成本差着几个小时。
指标:四类,不要更多
四类指标:
- 成功率与拒答率。 拒答率单独一个指标,而且它升高不一定是坏事——可能是第 12 篇的阈值在正常工作。要和引用正确率一起看:拒答率升、引用正确率也升,说明系统变诚实了;拒答率升、引用正确率没变,才是检索退化了。
- 首 token 延迟与总延迟。 流式场景下这两个数的意义完全不同。首 token 决定用户感知的「响应快不快」,总延迟决定「答完要等多久」。只报总延迟会掩盖首 token 的退化。
- 工具错误与审批等待。 按
tool_name和error_code分维度。审批等待时长的分布能告诉你审批超时时间设得合不合理。 - Token 与成本。 下一节单独说。
指标的维度(label)要控制基数。按 tool_name、error_code、model 分是安全的;按 user_id 或 conversation_id 分会让时序数据库爆掉——那些是 trace 该干的事,不是指标。
流式成本:需要 tiktoken 兜底
非流式请求的 usage 字段直接给出 token 数。流式响应默认不返回 usage,OpenAI 的解法是 stream_options: {include_usage: true}——但很多 OpenAI-compatible 服务端没实现这个参数。有的忽略它,有的直接报 400。
所以成本统计必须有兜底:
三个字段容易被漏掉,但缺了就没法回头分析:
estimated 标记这条记录是实测还是估算。混在一起算月度总成本,你不知道误差有多大。tiktoken 对中文的估算通常偏低 10% 左右,因为它的 BPE 是按英文语料训的。
pricing_version 记录用的是哪版价格表。模型降价或涨价之后,历史记录不该被重算——那会让上个月的成本曲线突然变形。
model 必须落库。同一个请求链路里可能用了三个模型:改写用小模型、重排用小模型、回答用大模型。混成一个数字,优化方向就错了:
分开记之后,一个常见的发现是:重排的成本占比远超预期。第 11 篇那个逐条打分的实现,输入是 20 条 chunk 拼起来的一万字符,每次问答都要跑一遍。它可能比主回答还贵。
Embedding 的成本要单独看,因为它的计费模式不同——只在索引时发生,是一次性投入,不随问答量增长。把它混进单问成本会让那个数字失真。
限流、超时与降级
限流采用 Redis 固定窗口计数器,按用户和 IP 双维度落地:
按 IP 限流是为了防未登录路径和批量注册;按用户限流才是主要防线。固定窗口实现极轻量,单次 INCR 即可判断;若需防范窗口边界突发流量,可平滑升级为滑动窗口或基于 Lua 的令牌桶。返回 429 时带上 Retry-After,让前端知道该等多久。
模型调用要设三个超时,不是一个:
connect 短,连不上要快速失败;read 长,因为流式响应的整体时长本来就长;额外还要有一个首 token 超时——连上了但 30 秒不吐第一个字,通常意味着上游排队严重,这时该降级而不是继续等。
降级路径要显式写出来,而且每一级都不能静默:
降级的两条纪律:
不能静默切换到权限更宽的模型或数据源。 主模型不可用时返回明确的「暂时无法回答」,比用一个没接权限过滤的备用链路给出答案要好得多。可用性不能拿正确性换。
每次降级都要打日志和指标。 降级如果不可见,它就会变成常态——系统一直在降级运行,报表上的延迟很漂亮,而没人知道回答质量已经掉了。
Docker Compose 拓扑
api 和 worker 用同一个镜像、不同 command。这保证它们的依赖版本永远一致——两个镜像分开构建,迟早会出现 worker 用旧版解析器、api 用新版 schema 的情况。
depends_on 只保证启动顺序,不保证服务已就绪。Postgres 容器启动到能接受连接有几秒差距,api 在这期间连接会失败。要在应用层重试:
有状态和无状态的区分决定了运维操作的边界。web、api、worker 可以随时重启、并行多份、直接换镜像回滚;postgres 和 redis 带数据卷,回滚要单独考虑。
发布与回滚
顺序是固定的:
- 执行向前兼容的数据库迁移(只加列、加表、加索引)
- 启动新版本的 api 和 worker
- 健康检查通过后切流
- 观察 15 分钟
关键规则:回滚只回滚镜像,不回滚已执行的迁移。
这条规则反过来约束了迁移的写法。「删掉一个列」不能一次做完,要拆成两次发布:
这样发布 N 出问题时,回滚到 N-1 的镜像仍然能工作,因为列还在。如果一次做完,回滚后旧代码会去读一个已经不存在的列,直接 500。
改列类型同理,要经过「加新列 → 双写 → 回填 → 切读 → 删旧列」这条路。这很啰嗦,但它是唯一能安全回滚的路径。
第 13 篇的 checkpoint 表在这里要单独提一句:它和业务表在同一个 postgres 里,但备份和清理策略不同。checkpoint 表增长很快(每个节点执行写一次),过期会话的检查点没有保留价值。配一个定期清理,保留最近 7 天,或只保留 approvals 里还有 pending 的那些 thread_id。
回到群里:机器人闭环
这个系统的语料来自群聊,但到目前为止答案只出现在 Web 界面上。用户得离开正在提问的地方,去另一个页面问同一个问题——这一步流失掉的人比你想的多。
最后一公里是把 Agent 接回群里。钉钉的 Stream 模式不需要公网回调地址,本地起一个长连接就能收消息:
四个和 Web 端不同的地方:
只处理明确 @ 的消息。 机器人本来也只能收到这些——这是平台的限制,不是你的选择。
身份要映射。 第 6 篇的 RBAC 依赖内部用户,而群里来的是 senderRef。映射不到就按最低权限处理,绝不因为「他在群里」就放行内部文档。
会话键带上会话引用。 同一个人在不同群里的追问是不同的上下文;共用一个会话会让第 12 篇的多轮字段收集串台。
渲染要换一套。 群消息不支持折叠引用面板,把引用压成两行尾注,超长回答截断并附 Web 链接。审批按钮在 IM 里是交互卡片,不是 Web 的按钮组件——但 approval_required 事件本身不用改,这是第 7 篇把审批做成协议而不是 UI 的回报。
图里那个回路是这个专栏真正的终点:群里的提问变成语料,Agent 用语料回答,回答本身又成为群里的新消息,下一次采集把它收进来。
这里有一个必须写进文档的边界,第 8 篇也提过:机器人只能收到满足触发条件的新消息,拿不到历史。 它是增量来源,不能替代数据库采集。如果哪天有人提议「用机器人收集就够了,不用做那套解密和 WAL 重放」,答案是不行——机器人上线之前的所有讨论,它一条都看不到。
还有一条运营上的:机器人答错会被全群看到。Web 端答错只有一个人尴尬,群里答错是公开的。所以群内回复的拒答阈值应该比 Web 端更保守,宁可说「这个我不确定,建了工单」。
