libincao
← ARTICLES
2026.09.03 · 14 MIN · 中文 · AI

Agent 的可观测性,重点不在 LLM 调用上:一年生产环境的实践笔记

市面上绝大多数 agent 观测工具的第一个功能,都是让你看到每次 LLM 调用的完整 prompt。但运营一个 agent 真正要回答的问题(它会不会发错邮件、会不会改错数据、这周策略拦了多少次),几乎没有一个需要看 prompt。审批、策略、预算、故障检测,全都发生在它「做事」的那一刻,而不是「思考」的那一刻。这是一年生产环境攒下的笔记:三个可观测边界各自能回答什么、审批为什么必须做成结构性而不是政策性、以及关于凭证泄漏,我建议直接给客户的那句实话。

做 agent 产品一年多,最反直觉的一条经验是:

Agent 的可观测性,重点不在 LLM 调用上。

这句话听起来不对。市面上绝大多数 agent 观测工具的第一个功能就是"看到每次 LLM 调用的完整 prompt 和 response",几乎所有人的第一直觉也是"在模型调用处埋点"。

我们之所以得出相反的结论,一开始是被迫的——我们的 agent loop 跑在一个托管平台上, tool 执行在供应商的 sandbox 里,我们物理上 hook 不到 LLM 调用。只能在别的地方 观测。

一年后回头看,这个约束反而是幸运的。因为运营一个 agent 真正需要回答的问题, 几乎没有一个需要看到 prompt。


一、三个边界,各自能回答什么

把 agent 的运行拆成三个可观测边界,会清楚很多:

边界 事件 能回答的问题
LLM 边界 每次模型调用 花了多少钱?上下文多大?cache 命中率?
Turn 边界 一次触发到一次空闲 它跑完了吗?卡住了吗?谁触发的?
Tool 边界 每次工具调用 它做了什么?该不该让它做?做过头了吗?

注意最后一行加粗的部分。运营侧真正焦虑的问题——它会不会发错邮件、会不会改错数据、 会不会把客户信息带到不该去的地方——全部落在 tool 边界上

原因很简单:审批、策略、预算、故障检测,都发生在 agent 要「做事」的那一刻, 而不是它「思考」的那一刻。 模型在想什么不产生副作用,模型调用工具才产生。

所以我们的优先级是反过来的:先建 tool 边界的控制,再建 turn 边界的可靠性检测, 最后才是 LLM 边界的成本与内容。这个顺序和大多数团队的直觉相反,但它是"管理平台" 和"trace 查看器"的分水岭。


二、事件模型:以 turn 为聚合根

我们的活动轨迹表大致长这样(字段名做了泛化):

CREATE TABLE agent_steps (
    id              BIGSERIAL PRIMARY KEY,
    agent_id        TEXT NOT NULL,
    session_id      TEXT NOT NULL,
    message_id      TEXT NOT NULL,      -- 一次 turn 的标识
    conversation_id TEXT NOT NULL,
    speaker_user_id TEXT,               -- 谁触发的;定时任务为 NULL
    channel         TEXT NOT NULL,      -- slack / email / web / system ...
    step_index      INTEGER NOT NULL,
    kind            TEXT NOT NULL,      -- prompt | tool_call | reply
    tool_name       TEXT,               -- kind='tool_call' 时有值
    target_summary  TEXT,               -- 调用打到哪个集成 / 回复预览
    status          TEXT NOT NULL,      -- ok | error | pending
    created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

为什么以 turn 而不是 session 为核心? 因为"一次触发到一次空闲"这个定义对所有 runtime 都成立,而 session 的语义各家不同(有的按线程、有的按频道、有的按工作目录)。 成本、审批、可靠性检测全都以 turn 为单位归集,session 只做弱分组。

一条 turn 展开成一串 step:prompttool_call × N → reply。Activity feed 就是这个序列的渲染。用户问"它刚才干了什么",答案是把这一串念出来。


三、观测上踩过的三个坑

坑 1:重投递会把轨迹写重

平台重投递、wedge 恢复、gateway 重试——任何一条都会让同一个 turn 被处理两次。 如果轨迹表是裸 INSERT,Activity feed 里就会出现两条一模一样的时间线。

修法是给它一个自然主键并让重复写入变成 no-op:

CREATE UNIQUE INDEX idx_agent_steps_session_msg
    ON agent_steps(session_id, message_id, step_index);
-- 写入时 ON CONFLICT DO NOTHING

顺带一提,同样的幂等问题在计费表上更严重。我们出过一次重复计费事故:同一条 消息,IM 平台会投递两个不同的事件(一个 mention 事件、一个消息事件),我们按 事件 ID 去重,于是两个都过了门,turn 跑了两遍、回复了两遍、账也记了两遍。

真正的不变量是消息身份,不是事件身份。改成按 (工作区, 频道 + 消息时间戳) 去重之后,问题消失了——而且这是老行为的严格超集,因为平台用新事件 ID 重投同一条 消息时,时间戳是不变的。

教训:去重键要选那个业务上唯一的东西,不是那个技术上现成的东西。

坑 2:best-effort 的轨迹会有洞,而且是静默的

这是我们最有教育意义的一次。

轨迹写入是 best-effort 的——写失败了记个日志,不影响主流程。听起来很合理:不能 因为记不上日志就不给用户回复。

然后有一天我们发现,某个 IM 渠道的活动轨迹整段消失了

原因:轨迹表的 channel 列有个 CHECK 约束。新渠道上线时,CREATE TABLE IF NOT EXISTS 在已存在的表上是 no-op,所以线上那张表的 CHECK 约束里没有这个新渠道。 每一次该渠道的 turn,轨迹写入都抛 CheckViolation——被 catch、被记日志、回复照常 发出去了

功能完全正常。用户毫无感知。监控全绿。只有轨迹是空的。

两条教训:

  1. CREATE TABLE IF NOT EXISTS 不会修改已存在的表。 加枚举值必须配一段 幂等的 ALTER … DROP CONSTRAINT … ADD CONSTRAINT,不能指望 CREATE 语句。
  2. "best-effort" 是一个需要被监控的决定,不是一个可以忘掉的决定。 我们现在 对轨迹写入失败率单独告警——一个降级路径如果没人看着它降级了没有,它就等于不存在。

坑 3:我们记了"发生了什么",没记"为什么允许"

这是我们到现在还没补的缺口,写出来是因为它很典型。

agent_steps.status 只有三个值:ok | error | pending。没有 denied,也没有 一个 policy_level 列。

结果是:策略引擎本身是不可观测的。 Activity feed 里看不出"这次调用被策略拦下 了"和"这次调用失败了"的区别;也没法回答"这周策略一共拦了多少次、拦的都是什么"。

我们花了很大力气建拦截层,却没有为拦截层本身留观测点。这是一个很容易犯的错—— 你会本能地记录业务事件,但控制面的决策同样是事件。


四、权限管理:四层,各管一件事

工具调用的权限不是一个开关,是四层,每层挡一类攻击:

1. 路由层    —— 平台签名决定这个请求属于哪个租户,伪造不了
2. 成员层    —— 角色决定谁能改策略、谁能批准、谁能看凭证
3. 能力层    —— 签名 URL 决定 agent 能碰哪些工具路由
4. 策略层    —— 逐次调用比对策略表,决定 allow / deny / 需审批

第 4 层的解析顺序是:

具体规则 (namespace, tool)  >  应用默认 (namespace, *)  >  全局默认 ('auto')

同时维护一张 observed_tools 表:任何工具第一次被调用时自动登记,让策略界面 能看到"这个 agent 实际上能调什么",而不是让人凭空去列。

这里有个诚实的取舍要说:默认是 auto,也就是宽松。 反过来(默认需审批)的 失败模式更糟——每次调用都弹审批卡,用户很快就不看内容、条件反射地点批准,此时 审批机制比没有更糟,因为它看起来像监督。

所以我们的策略是:默认放行,但用下面三个机制兜住尾部风险。


五、审批要"结构性",不能只是"政策性"

这一节是整篇里我最想分享的部分。

冻结参数

审批流程的经典漏洞:人批准了"给张三发一封内容是 X 的邮件",agent 拿到批准后 重新构造了一次调用,参数变成了"给李四发内容是 Y 的邮件"。

很多实现靠"比对参数 hash"来防这件事。我们的做法是让它结构上不可能发生

审批人看到的 JSON 和最终执行的 JSON 是同一行数据的两次读取。参数漂移不是被 策略禁止的,是没有表达途径。

原则:能用类型/schema 消灭的风险,不要用校验逻辑去防。

原子领取

第二个坑:两个并发的 submit_draft(agent 超时重试、用户点了两次批准、网络重放) 会双双通过 SELECT status='approved' 的检查,然后双双打到上游。

我们真的让某个广告平台收到了两次一模一样的建单请求。

修法是把"读"变成"原子状态转移":

UPDATE drafts
   SET status = 'submitted', submitted_at = NOW()
 WHERE id = $1 AND status = 'approved' AND agent_id = $2 AND expires_at > NOW()
RETURNING *;

Postgres 会串行化这条 UPDATE,只有一个调用者能满足 status='approved',输的那个 拿不到行,直接返回"未就绪",不会打到上游

原则:任何"先查状态再执行"的代码,在并发下都是错的。把它写成一条带条件的 UPDATE。

爆炸半径计数器

第三个,也是最容易被忽略的一类风险。

单次的破坏性调用,策略层能挡。真正难防的是:agent 做了一件单看完全合理的事 ——发一封邮件、更新一条记录——然后因为循环没终止、或者列表比它以为的长,又做了 六十次。每一次单独看都值得批准,这恰恰是逐次审批救不了你的原因

我们的做法是一个滑动窗口计数器,键是 (agent, 工具, 会话线程)

INSERT INTO write_counters (agent_id, tool_key, thread_key, n, updated_at)
VALUES ($1, $2, $3, 1, NOW())
ON CONFLICT (agent_id, tool_key, thread_key) DO UPDATE
   SET n = CASE WHEN write_counters.updated_at < NOW() - make_interval(secs => $4)
                THEN 1
                ELSE write_counters.n + 1
           END,
       updated_at = NOW()
RETURNING n;

超过阈值后,后续本来 auto 的写调用自动改道成审批

为什么必须是单条原子语句? 因为 agent SDK 的批量调用路径会并发打出 8 个请求。 "先读计数、再判断、再写回"这三步在并发下会让 8 个请求全部读到 n < 阈值、全部放行。 交给 Postgres 在一条语句里串行化,每个调用者拿到自己的自增后值——这才是一道门, 否则只是一个建议。

窗口和审批卡的折叠窗口用同一个值(我们取 300 秒),这样计数器和卡片会一起重置, 不会出现"卡片重置了但计数还在"的错位。


六、敏感数据泄漏:真正的边界在哪

这是被客户安全团队问得最多的一块,我建议直接给诚实的答案,因为包装过的答案在 第二个追问就会破。

凭证不进 agent

最有效的一条:第三方凭证根本不下发给 agent。

我们在 agent 和真实工具服务之间放了一个代理。agent 拿到的是一个不含任何密钥的 URL;代理验证请求合法之后,才在服务端注入真实凭证转发出去。凭证从不进入 agent 的上下文、不进入 sandbox、不进入任何可能被模型读到的地方。

这个设计一开始是被逼的:我们用的 agent SDK 在配置工具服务时只接受一个裸 URL{name, type: "url", url}),没有 header 字段、没有 auth 字段。既然 URL 是唯一 的载体,那就只有两条路——把凭证塞进 URL,或者让 URL 本身成为一张凭证

我们选了后者:对 (provider, 租户, 应用, 账号, 说话人) 做 HMAC-SHA256 签名, 签名结果就是 URL 的一部分;代理端用同一个密钥重算并做常数时间比较。

轮换方式是两端同时换密钥,然后重新解析一遍 agent 配置,铸出全新的 URL。

一个我们做错的地方:签名没绑 method

坦白一个设计债。我们的签名 payload 绑定了身份(谁、哪个应用、哪个账号), 但没有绑定具体调用哪个工具

后果是:一个签名 URL 授权了该路由上的所有工具。当我们后来在同一条路由上加了 两个写操作工具,这个 URL 的能力就从"读取凭证"悄悄扩大到"还能发起一次授权流程"—— 虽然那个写操作还有别的约束兜底(必须走人类浏览器批准、只能写入白名单内的名字), 但能力粒度粗于策略粒度这件事本身就是个不对齐。

如果重来一次,我会一开始就把工具名纳入签名 payload,或者干脆用短期 token 而不是 永久签名。

教训:capability 的粒度要和 policy 的粒度对齐。策略说"按工具管",签名就不能 "按路由发"。

可见性要窄于成员关系

团队里所有人都是成员,但不是所有人都该看到所有凭证。我们给每条 secret 加了 scope + 邀请列表两层过滤,默认可见性窄于团队成员关系,需要显式授予。

然后是那句必须说的实话

如果模型能在 sandbox 里跑 bash,而某个凭证在那个环境里,模型就能读到它

在"一个能使用凭证的 agent"和"一个能外泄凭证的 agent"之间,不存在密码学边界。 任何 agent 产品声称相反的说法都是错的。

所以我们的论证是围堵,不是防止

给安全团队的建议永远是同一句:发最小权限的凭证,把 agent 当成一个"拥有你给它 的权限的员工"来对待。 如果他们需要密码学层面的不可外泄,那我们就是错的产品—— 这句话我宁愿在售前说,也不愿在事故复盘会上说。

内容采集默认关闭

最后一条容易被忽略:观测本身就是一条泄漏路径。

如果为了 debug 而把每次 LLM 调用的完整上下文上报到观测平台,那你等于把客户的 所有数据复制了一份到另一个系统。我们的默认是:

顺便说一个成本上的发现:如果真要采集 LLM 内容,必须做增量上报。agent loop 每一步都会重发完整上下文,一轮 20 步、上下文 100k token 的对话,原样上报是 2M token 的重复数据,其中 95% 是同一个前缀。正确做法是只传相对上一次的增量 + 前缀 hash,服务端重建。


七、能拦住 ≠ 能证明

这是我们目前最明显的短板,也是我认为很多团队会踩到的地方。

我们的审计其实是分层的,而且分得不太对:

载体 保证
控制面变更(改策略、批准、改凭证配置) audit_events,与主操作同事务 可靠
数据面动作(agent 实际调用了什么工具) 活动轨迹表,best-effort 可能有洞

看出问题了吗——风险最高的那一层,用的是保证最弱的载体。

当客户问"上个月你们的 agent 到底动过我系统里什么",答案需要 join 四张表才能拼出来, 而其中最关键的那张不保证完整(见坑 2)。

如果要往企业客户走,这块要补三样东西:

  1. 数据面动作也进事务性审计,至少高危工具要进;
  2. 一个统一的审计查询接口,而不是让人去 join;
  3. 防篡改(append-only 强制或 hash 链)+ 明确的保留策略。

前两条是工程量问题,第三条是做合规认证时会被直接问到的。


八、最贵的失败是静默的

最后一块,也是我认为最被低估的一块。

一个大声报错的 agent 是没事的——用户会告诉你。真正让客户流失的,是那些 看起来什么都没错的。

我们跨租户扫六类信号:

# 信号 检测方式
1 失败的定时任务 运行记录里的 error,按错误类型聚合
2 失败的工具调用 轨迹表里 status='error',按 (agent, 工具) 聚合
3 空跑的定时任务 触发成功 N 次,但同窗口内没有任何该来源的 turn
4 卡死的 turn 在飞状态超过阈值
5 投递黑洞 回复写入连续失败
6 搁浅的审批 待批准记录超过有效期——那个被门控的动作从来没执行,也没人发现

最难的是第 3 类。 断路器抓不到它,因为每次运行都是绿的——失败是下游效果的 缺席,而缺席没有 error code。只能靠对比"预期触发"和"实际发生"。

这类检测只能靠调度边界和 turn 边界的数据。回到开头那句话:在 LLM 边界埋再多点, 也检测不到这类故障。

Shadow 模式是默认

一个流程上的建议:这类检测先只记录,不通知

在客户其实没坏的时候告诉他"你的自动化坏了",比保持沉默更糟——它烧掉的正是这个 功能本该建立的信任。

我们的门槛是:跑满一个观察期,看 would-notify 分布小而且周对周稳定,再人工审计 一整周的记录确认被标记的确实是坏的,然后才开对外通知。

Shadow 模式是你赢得"打扰别人"这项权利的方式。


九、如果重来一次

按投入产出比排的实施顺序:

顺序 做什么 为什么在这个位置
1 Tool 边界的轨迹 + 幂等键 一切的地基;没有它后面全是猜
2 策略表 + 逐次拦截 + observed_tools 从"能看"到"能管"的分界线
3 冻结参数的审批 + 原子领取 有了它,高危工具才敢开给客户
4 爆炸半径计数器 便宜,挡住最难防的那类风险
5 静默失败检测(shadow) 客户流失的真正原因
6 凭证代理注入 + 签名(一开始就绑 method 越早越好,改造成本随集成数增长
7 LLM 边界的成本与内容 最后。它最贵、信息密度最低、泄漏风险最高

第 7 项排最后,是这篇文章的全部论点。


收尾

如果只能记住三句话:

  1. 在动作边界上管,在思考边界上看。 审批、策略、预算、故障检测都发生在 tool 边界,不在 LLM 边界。
  2. 能用 schema 消灭的风险,不要用校验逻辑去防。 冻结参数 + 只接受 ID 的提交 接口,比任何参数比对都可靠。
  3. "能拦住"和"能证明"是两件事。 前者是产品能力,后者是合规能力,它们需要 不同强度的保证——而大多数团队(包括我们)在后者上欠了债。