1. 为什么“AI员工”光记日志远远不够
1.1 从“能跑”到“敢用”的那道坎
过去一年,我陆续帮几个团队把内部流程里的重复性工作交给 AI 员工来做——自动处理工单、自动回复客户咨询、自动整理数据报表。刚开始大家都很兴奋,觉得效率翻倍。但真正上线跑了两周之后,问题就来了:某天早上运营同事跑过来问,“昨天那批客户咨询,为什么有三条回复的内容完全不对?”我打开系统一看,日志里只有一行task completed,至于它当时看到了什么、调用了哪个工具、中间经过了哪些判断,全都没有记录。那一刻我才意识到,AI 员工和传统程序最大的区别在于:它的行为是不确定的。传统程序出 bug,你可以复现;AI 员工出问题,你连它当时“在想什么”都不知道。
这就是“可观测性”要解决的核心问题。很多人把可观测性等同于“记日志”,其实差得远。日志只是最基础的一层,真正完整的可观测性要能回答三个问题:发生了什么(日志)、为什么发生(追踪与上下文)、能不能重来一遍(可重放)。对于 AI 员工这种带推理、带工具调用、带外部依赖的系统来说,第三点尤其关键——因为它的输出往往不可复现,你只有把当时的完整执行上下文保存下来,才能在事后“重放”那一次执行,定位到底是提示词的问题、工具返回的问题,还是模型本身抽风。
我写这篇东西,是想把过去大半年踩过的坑、试过的方案、最后沉淀下来的架构讲清楚。适合两类人看:一类是正在做 AI 员工/智能体系统、被线上问题折磨的工程师;另一类是刚开始接触可观测性、想知道怎么把它落到 AI 场景里的同学。不需要你有多深的分布式追踪背景,我会尽量用大白话把原理和实操都讲透。
1.2 一个真实的翻车现场
先讲个具体案例,方便后面展开。我们有个 AI 员工负责处理售后工单,流程大概是:读取工单内容 → 判断问题类型 → 查询订单系统 → 生成回复 → 必要时转人工。有一次客户投诉说“明明我的订单已经发货了,AI 却回复说没找到订单”。我去查日志,发现只有一条order_query的调用记录,返回结果是空。但问题是,这个订单在系统里明明存在。
后来我们花了整整一个下午才定位到:AI 员工在调用订单查询工具时,把订单号里的字母O识别成了数字0。这个错误发生在模型生成工具参数的那一步,而我们的日志只记录了“调用了工具”,没有记录“调用时传了什么参数”。如果当时有完整的执行追踪,把每一步的输入输出都存下来,这个问题五分钟就能定位。更理想的情况是,如果支持重放,我可以直接拿当时那次执行的上下文重新跑一遍,改一下参数验证逻辑,立刻验证修复是否有效。
这个案例说明了一件事:AI 员工的可观测性,必须覆盖“输入 → 推理 → 工具调用 → 输出”的完整链路,而且每一步都要能回放。下面我就按这个思路,从整体设计讲到具体实现。
2. 整体设计:三层可观测性架构怎么搭
2.1 先想清楚要观测什么
动手之前,我建议先列一张“观测清单”。AI 员工的执行链路通常包含这几个环节,每个环节都有对应的观测需求:
| 执行环节 | 要观测的内容 | 典型问题 |
|---|---|---|
| 接收输入 | 用户原始输入、会话上下文、系统提示词 | 输入被截断、上下文丢失 |
| 模型推理 | 完整提示词、模型返回、token 消耗、耗时 | 提示词注入、模型幻觉 |
| 工具调用 | 工具名、入参、返回值、耗时、异常 | 参数错误、超时、返回异常 |
| 结果输出 | 最终回复、后处理逻辑、输出格式 | 格式错误、内容不当 |
| 状态流转 | 任务状态变化、重试次数、转人工触发 | 死循环、状态卡死 |
这张表是我踩坑之后总结的,一开始我只记了“工具调用”和“结果输出”,结果模型推理那一段成了黑盒,出了问题根本没法查。后来补上完整提示词记录,才发现很多问题其实出在提示词拼接环节——比如上下文太长被截断,导致模型“看不到”关键信息。
注意:记录完整提示词会涉及用户隐私和敏感数据,生产环境一定要做脱敏处理,或者只记录哈希值和长度,需要排查时再通过安全通道获取原文。
2.2 三层架构的分工
我把整个可观测性体系分成三层,每层职责清晰,互不干扰:
第一层是采集层,负责在各个执行节点埋点,把原始数据抓出来。这一层的关键是“无侵入”——不能因为加了观测就拖慢主流程。我的做法是在执行网关里统一埋点,所有 AI 员工的执行都经过网关,网关负责在关键节点打点,业务代码基本不用改。
第二层是存储与索引层,负责把采集到的数据存下来,并且能快速检索。这里有个选型问题:日志、追踪、指标要不要分开存?我的经验是,执行链路数据用结构化存储(比如 JSON 文档或宽表),指标数据用时序库,两者通过 trace_id 关联。不要试图用一套系统解决所有问题,否则查询会非常痛苦。
第三层是重放与查询层,负责把一次执行完整还原出来,支持“重新跑一遍”。这一层是 AI 员工可观测性区别于传统系统的核心。传统系统重放靠的是“输入 + 代码版本”,AI 员工重放还需要“模型版本 + 提示词版本 + 工具版本 + 随机种子”,缺一不可。
2.3 为什么选“执行网关”作为埋点中心
市面上埋点方式主要有三种:SDK 埋点、代理埋点、网关埋点。我最后选了网关埋点,原因很实际:
- SDK 埋点需要在每个 AI 员工里引入依赖,改造成本高,而且不同团队用的框架不一样,维护起来很累。
- 代理埋点(比如在模型调用层做拦截)能覆盖模型调用,但覆盖不了工具调用和业务逻辑。
- 网关埋点把所有执行请求统一收口,不管底层用什么框架、调什么模型,都经过网关,埋点一次到位。
具体做法是:AI 员工的每次任务执行,都向执行网关注册一个trace,网关生成全局唯一的trace_id,然后在每个关键节点(模型调用前后、工具调用前后、输出前后)记录结构化事件。这些事件按时间顺序串起来,就是一次完整的执行链路。
# 执行网关埋点的简化示意 class ExecutionGateway: def execute(self, task): trace_id = generate_trace_id() self.record(trace_id, "task_start", { "input": task.input, "context": task.context, "prompt_version": task.prompt_version, "model_version": task.model_version }) try: result = self.run_pipeline(task, trace_id) self.record(trace_id, "task_end", {"result": result}) return result except Exception as e: self.record(trace_id, "task_error", {"error": str(e)}) raise这段代码只是示意,实际实现要考虑异步、批量写入、失败降级等问题。核心思想是:埋点逻辑集中在网关,业务代码只负责传上下文。
3. 核心细节:日志、追踪、重放各自怎么落地
3.1 日志不是越多越好,而是要“结构化”
很多人一提到记日志,就是print或者logger.info("something happened")。这种日志在 AI 员工场景里基本没用,因为你需要的是能检索、能聚合、能关联的结构化数据。
我的做法是统一日志格式,每条日志都是一个 JSON 对象,包含固定字段:
{ "trace_id": "abc-123", "span_id": "span-001", "timestamp": "2024-01-15T10:23:45.123Z", "event_type": "tool_call", "tool_name": "order_query", "input": {"order_id": "O12345"}, "output": {"status": "not_found"}, "duration_ms": 234, "status": "success" }这样存下来之后,你可以按trace_id把一次执行的所有事件串起来,也可以按tool_name统计某个工具的失败率,还可以按duration_ms找出慢调用。结构化的价值在于,它让日志从“给人看的文本”变成了“给系统查的数据”。
实操心得:字段命名一定要统一,比如时间戳统一用 ISO 8601,状态统一用
success/failure/timeout,不要一会儿ok一会儿success。我一开始没注意,后来做聚合查询时被坑得很惨。
3.2 追踪链路:把一次执行串成一条线
日志是点,追踪是线。AI 员工的一次执行往往涉及多次模型调用和工具调用,这些调用之间有先后依赖关系,必须用trace_id和span_id把它们串起来。
具体做法是:网关生成trace_id后,每次模型调用或工具调用都生成一个子span_id,并记录parent_span_id。这样就能还原出一棵树:
trace: abc-123 ├── span: task_start ├── span: model_call_1 (判断问题类型) │ └── span: tool_call_1 (查询订单) ├── span: model_call_2 (生成回复) └── span: task_end有了这棵树,排查问题时就能快速定位是哪一步出了岔子。比如前面那个订单号识别错误的问题,一看tool_call_1的入参是O12345而不是012345,立刻就知道是模型生成参数时出的错。
这里有个细节要注意:异步调用和并行调用的 span 关系要处理好。AI 员工有时候会并行调用多个工具,这时候不能简单用父子关系,要用“兄弟关系”加时间戳来还原并行结构。我试过用简单的顺序记录,结果并行调用的耗时统计完全不对,后来改成记录start_time和end_time,用时间轴来还原才准确。
3.3 可重放:最难但最值钱的一层
可重放是 AI 员工可观测性的“圣杯”。它的核心思想是:把一次执行的所有输入、上下文、版本信息都存下来,需要时能原样重新执行一遍。
要实现重放,必须记录这几类信息:
- 输入数据:用户原始输入、会话历史、系统提示词
- 版本信息:模型版本、提示词版本、工具版本、代码版本
- 执行参数:温度、top_p、随机种子等
- 外部依赖快照:工具调用返回的数据(因为外部系统可能已经变了)
最后一点特别关键。比如你重放一次订单查询,如果直接调实时接口,返回的可能是最新状态,而不是当时的状态。所以工具调用的返回值必须快照存储,重放时用快照而不是实时调用。
# 重放时的工具调用拦截 class ReplayToolWrapper: def __init__(self, original_tool, snapshot_store): self.original_tool = original_tool self.snapshot_store = snapshot_store def call(self, trace_id, tool_name, params): if self.is_replaying: # 重放模式:从快照读取 return self.snapshot_store.get(trace_id, tool_name, params) else: # 正常模式:实时调用并快照 result = self.original_tool.call(params) self.snapshot_store.save(trace_id, tool_name, params, result) return result这个设计的好处是,重放时不会对外部系统产生副作用,也不会因为外部数据变化导致重放结果不一致。
注意:重放不是万能的。如果 AI 员工的行为依赖实时数据(比如股票价格),重放只能还原“当时看到的数据”,不能还原“如果当时数据不同会怎样”。这一点要在使用重放时心里有数。
4. 实操过程:从零搭一套可观测性体系
4.1 第一步:定义事件模型和存储结构
动手写代码之前,先把事件模型定下来。我建议用“事件表 + 快照表”两张表:
事件表存执行链路的所有事件,字段包括:trace_id、span_id、parent_span_id、event_type、timestamp、payload(JSON)、duration_ms、status。
快照表存工具调用的输入输出快照,字段包括:trace_id、tool_name、params_hash、params、result、timestamp。
存储选型上,如果数据量不大(每天百万级事件以内),用 PostgreSQL 的 JSONB 字段就够用,查询也方便。数据量再大可以考虑 ClickHouse 或者 Elasticsearch。我一开始用的是 MongoDB,后来发现聚合查询不如 PostgreSQL 顺手,就换回来了。
CREATE TABLE execution_events ( trace_id VARCHAR(64) NOT NULL, span_id VARCHAR(64) NOT NULL, parent_span_id VARCHAR(64), event_type VARCHAR(32) NOT NULL, timestamp TIMESTAMPTZ NOT NULL, payload JSONB, duration_ms INTEGER, status VARCHAR(16), PRIMARY KEY (trace_id, span_id) ); CREATE INDEX idx_trace ON execution_events(trace_id); CREATE INDEX idx_type_time ON execution_events(event_type, timestamp);索引很关键。我一开始只建了主键索引,查询慢得离谱,后来补上trace_id和event_type + timestamp的索引,查询速度从几秒降到几十毫秒。
4.2 第二步:在执行网关里埋点
网关埋点的核心是“切面”。不管 AI 员工内部怎么实现,网关在几个关键切面统一打点:
- 任务开始:记录输入、上下文、版本信息
- 模型调用前:记录完整提示词
- 模型调用后:记录模型返回、token 消耗、耗时
- 工具调用前:记录工具名和入参
- 工具调用后:记录返回值和耗时
- 任务结束:记录最终输出和总耗时
def run_pipeline(self, task, trace_id): # 模型调用埋点 self.record(trace_id, "model_call_start", { "prompt": task.prompt, "model": task.model_version }) model_result = self.model.call(task.prompt) self.record(trace_id, "model_call_end", { "output": model_result.text, "tokens": model_result.usage, "duration_ms": model_result.duration }) # 工具调用埋点 for tool_call in model_result.tool_calls: self.record(trace_id, "tool_call_start", { "tool": tool_call.name, "params": tool_call.params }) tool_result = self.tools.call(tool_call.name, tool_call.params) self.record(trace_id, "tool_call_end", { "result": tool_result, "duration_ms": tool_result.duration })埋点写入要用异步批量方式,不能阻塞主流程。我的做法是先把事件写到内存队列,后台线程批量刷到数据库。这样即使数据库短暂不可用,也不会影响 AI 员工的正常执行。
4.3 第三步:实现重放引擎
重放引擎的核心是“拦截 + 替换”。正常执行时,工具调用走实时接口并快照;重放时,工具调用走快照读取。
class ReplayEngine: def replay(self, trace_id): # 加载原始执行的所有事件 events = self.load_events(trace_id) # 提取输入和版本信息 task_start = find_event(events, "task_start") input_data = task_start.payload["input"] versions = { "model": task_start.payload["model_version"], "prompt": task_start.payload["prompt_version"] } # 用快照模式重新执行 with self.snapshot_mode(trace_id): result = self.gateway.execute( input_data, versions=versions, replay=True ) return result重放的时候要注意:模型调用本身可能不完全可复现,即使温度设为 0,不同批次的推理结果也可能有细微差异。所以重放的价值不在于“得到完全一样的结果”,而在于“验证修复逻辑是否生效”。比如你改了参数校验逻辑,重放时就能看到新的校验是否能拦住之前的错误参数。
4.4 第四步:搭一个查询面板
数据存下来之后,得有个地方能查。我用的是最简单的方案:一个 Web 页面,输入trace_id就能看到完整执行链路,每个节点可以展开看详情。
面板要支持几个核心功能:
- 按 trace_id 查询:还原单次执行
- 按时间范围 + 事件类型查询:找特定类型的问题
- 按工具名统计:看哪个工具失败率高
- 一键重放:选中某次执行,点击重放
这个面板不需要多漂亮,但一定要快。我见过太多团队花大力气做可视化,结果查询慢得要死,最后没人用。查询性能比界面美观重要十倍。
5. 常见问题与排查技巧实录
5.1 日志写入拖慢主流程怎么办
这是最常见的问题。一开始我直接在业务代码里同步写数据库,结果 AI 员工的响应时间从 2 秒涨到 5 秒。后来改成异步批量写入,响应时间基本没受影响。
具体做法:用内存队列缓冲事件,后台线程每 100 毫秒或每 100 条批量写入一次。队列满了就丢弃低优先级事件(比如调试日志),保证核心事件不丢。
class AsyncEventWriter: def __init__(self, batch_size=100, flush_interval=0.1): self.queue = Queue(maxsize=10000) self.batch_size = batch_size self.flush_interval = flush_interval self.worker = Thread(target=self._flush_loop, daemon=True) self.worker.start() def write(self, event): try: self.queue.put_nowait(event) except QueueFull: # 队列满时丢弃调试级别事件 if event.level != "debug": self.queue.put(event, timeout=0.1)实操心得:队列大小要压测过再定。我一开始设了 1000,结果高峰期丢了不少事件,后来改成 10000 才稳住。但也不能无限大,否则内存会爆。
5.2 重放时结果不一致怎么排查
重放结果不一致,通常有三个原因:
| 原因 | 表现 | 排查方法 |
|---|---|---|
| 模型版本不一致 | 输出风格或内容差异大 | 检查重放时用的模型版本是否和原始一致 |
| 外部数据变化 | 工具返回结果不同 | 确认是否走了快照模式 |
| 随机性 | 细微差异 | 检查温度、种子参数是否一致 |
我遇到最多的是第二种:重放时忘了走快照,直接调了实时接口,结果外部数据已经变了。解决办法是在重放引擎里强制拦截所有工具调用,不允许走实时接口。
5.3 敏感数据怎么处理
完整记录提示词和工具返回值,必然会涉及敏感数据。我的做法是三层处理:
- 第一层:字段级脱敏。手机号、身份证号、银行卡号等敏感字段在写入前替换成掩码。
- 第二层:访问控制。查询面板需要权限,普通开发只能看脱敏后的数据,只有特定角色能申请查看原文。
- 第三层:定期清理。原始数据保留 30 天,之后只保留脱敏版本和统计指标。
注意:脱敏逻辑要放在网关层统一做,不能指望每个业务自己处理。我见过有团队让业务代码自己脱敏,结果漏了好几个字段,出了安全事故。
5.4 常见问题速查表
| 问题 | 可能原因 | 解决方向 |
|---|---|---|
| 查不到某次执行的日志 | trace_id 生成失败或写入丢失 | 检查网关埋点是否覆盖所有入口 |
| 日志时间戳乱序 | 异步写入导致 | 用事件产生时间而非写入时间排序 |
| 重放报错找不到快照 | 快照未保存或已清理 | 检查快照保存逻辑和保留策略 |
| 查询面板加载慢 | 索引缺失或数据量过大 | 补索引,考虑冷热数据分离 |
| 工具调用耗时统计不准 | 并行调用未正确处理 | 用 start/end 时间戳而非顺序累加 |
6. 几个我踩过的坑和最后的体会
6.1 不要一开始就追求大而全
我最初的设计想把日志、指标、追踪、重放全做进去,结果做了两个月还没上线。后来砍掉指标和复杂追踪,先做“结构化日志 + 基础重放”,两周就上线了。上线之后根据实际需求再逐步补,反而效率更高。可观测性体系是长出来的,不是设计出来的。
6.2 重放的价值在“验证”不在“复现”
很多人对重放的期待是“完全复现当时的结果”,但实际上由于模型的随机性,完全复现很难。重放真正的价值是:当你修复了一个 bug,可以用历史数据验证修复是否有效。比如你加了一个参数校验规则,重放历史执行,看有多少次会被新规则拦住,这比写单元测试更贴近真实场景。
6.3 存储成本要提前算
完整记录提示词和工具返回值,数据量增长很快。我算过一笔账:一次执行平均产生 5KB 数据,每天 10 万次执行就是 500MB,一个月 15GB。如果保留一年,就是 180GB。这还不算索引和备份。所以保留策略一定要提前定,原始数据保留 30 天,聚合指标长期保留,这样成本可控。
6.4 最后分享一个小技巧
如果你刚开始做,不知道从哪下手,可以先从“记录完整提示词”开始。这是投入最小、收益最大的一步。很多 AI 员工的问题,光看提示词就能定位一大半。等这一步跑顺了,再逐步加工具调用记录、追踪链路、重放能力。一步一步来,比一上来就搞大架构靠谱得多。
这套东西我们跑了半年多,线上问题的平均定位时间从原来的两三个小时降到了二十分钟以内。最明显的变化是,以前出了问题大家互相甩锅,现在直接甩 trace_id,谁的问题一目了然。这大概就是可观测性最实在的价值。