一份关于日志实现方式、必备特性与落地取舍的技术分析
面向做 AIOps / RAG / Agent 平台的工程与运维同学,供借鉴、拍砖和讨论
写在前面:一次让我改设计的追问
我们做的是一个骨干网 SDN 运维知识平台:十几份设备手册和 API 文档切成 815 个片段,向量库加 BM25 混合检索,后面挂一个本地部署的 7B 模型负责把证据组织成回答。某次演示,有人指着一条建议问了一句:
「它为什么建议在这台设备上改 BGP 配置?依据是哪份文档的哪一节?当时用的是哪个模型?」
我翻了日志,答不上来。
日志里有什么呢:接口访问记录、检索耗时、一次 LLM 调用的 token 数和延迟、回答的文本。够排障,够算成本,但不够复现那个判断。因为答案的依据不在模型里,在检索回来的那几条 chunk 里;而那几条 chunk 的 id、章节、名次分,当时没进日志。
后来我们补了引用快照、模型版本、会话里的 citations 和 tool_calls,又用一个 run_id 把问答、检索、模型调用、工具执行串起来。补完之后我才意识到,改动的不只是字段------日志这件事的定位变了。
传统系统里,日志是「系统做了什么」的记录。系统开始自己做判断之后,日志得回答「系统凭什么这么做」。这篇文章把这两者的差别、我们踩过的坑、以及一套能落地的实现方案写下来。代码骨架我单独写了个可运行的脚本,跑通的输出贴在第十一节,不是纸上设计。
背景数据都做过泛化处理,但数字是真的:单机 16 核无 GPU,7B 模型 4bit 量化后 4.7GB,纯 CPU 解码约 8.5 tok/s,冷加载 5.85 秒,一次 60 token 的生成端到端 9.7 秒。这些数字后面会反复用到,因为它们直接决定日志该记多少、什么时候记。
一、先拆清楚:传统系统的日志在解决什么
传统服务的日志体系,不管用 ELK、Loki 还是自家埋点,基本围着三件事转:
排障。 异常堆栈、请求耗时、上下游返回码、慢查询。出问题时按时间线和 trace_id 把一次请求拉出来,看哪一段红了。
审计。 谁在什么时候调了哪个接口、改了哪条数据。合规检查时导出一份,证明「没乱来」。
统计。 QPS、错误率、P99、资源水位。日志经聚合变成指标,指标触发告警。
配套的技术已经很成熟:日志分级(DEBUG/INFO/WARN/ERROR)、结构化 JSON、appender 异步刷盘、集中收集与检索、OpenTelemetry 的 trace/span、按采样率留痕、按保留期滚动删除。
这套东西能成立,靠的是四个很少被明说的假设:
| 假设 | 含义 |
|---|---|
| 确定性 | 同样的输入、同样的代码版本,输出基本一致;出问题时重放请求就能复现 |
| 执行者明确 | 发起动作的是一个确定的人或一个确定的服务,写进 user_id / service_name 就够了 |
| 依据在代码里 | 系统为什么这么答,读代码和配置就能推导出来,不需要额外记「依据」 |
| 事件粒度稳定 | 一次请求 = 一串 HTTP/RPC 调用,trace_id 天然能把它们串起来 |
微服务时代这些假设被挑战过一次(调用链变长、跨服务),但 OpenTelemetry 把 trace_id 铺开之后,问题基本还是被解决了。
二、LLM 进了链路,四个假设一起塌了
确定性没了。 同一个问题问两次,措辞、引用顺序、甚至结论侧重都可能不同。温度参数、模型版本、prompt 模板、检索命中集合,任何一个变了结果就变。「重放请求」不再等于「复现当时」。所以日志必须记输入快照,而不只是结果。
执行者模糊了。 一条变更建议可能是模型提的、人点头的、系统执行的。定责时问「谁让它发生的」,答案有三层。只记 user_id 会把责任错挂在点击的人身上,只记 model 又找不到批准者。
依据跑到系统外面去了。 RAG 架构里,知识不在模型参数里,在向量库那 815 个 chunk 里。模型只是把检索到的证据组织成话。日志如果不记「当时喂给了它哪几条证据」,事后就无从判断这条回答是有据可依还是编的。这是和传统系统最本质的区别------答案的依据成了运行时数据,而不是代码。
动作会被放大。 传统系统里,一次请求最多改几条库记录。智能运维系统里,一句话可能走完「检索 → 模型规划 → 调工具 → 下发设备配置」。破坏面从一条错误答案,扩大到一台设备甚至一张网。日志因此必须覆盖到「动作」和「动作的结果」,还要能支撑回滚。
还有一层容易被忽略的:成本和质量也成了日志要回答的问题。一次问答调了几次模型、用了多少 token、有没有降级到规则兜底、降级是因为超时还是模型挂了------这些在传统系统里根本不存在。
三、一张对照表
| 维度 | 传统系统 | 智能运维系统 |
|---|---|---|
| 记录对象 | 事件(发生了什么) | 事件 + 决策(凭什么这么判断) |
| 关联键 | trace_id / span_id |
run_id + span_id + evidence_id + op_id |
| 输入 | 通常不记全文,够排障即可 | query 原文、改写后的检索词、最终拼装的 messages(或哈希) |
| 依据 | 在代码里,不记 | 检索命中的 chunk id / 文档 / 章节 / 分数 / 工具返回的结构化数据 |
| 模型信息 | 无 | 模型 id、版本 tag、temperature、max_tokens、base_url |
| 输出确定性 | 确定 | 不确定,需内容哈希 + 全文另存会话 |
| 执行者 | 人 / 服务 | 人 / LLM / 系统三层,且要记「建议者」与「批准者」 |
| 成本视角 | 无 | token、调用次数、降级次数、失败率、p95 |
| 时间维度 | 一次请求毫秒级 | 一次问答可能十几秒,且分 prefill / decode / 冷加载,慢在哪要分开记 |
| 合规要求 | 留痕即可 | 留痕 + 不可篡改 + 可复现 + 决策可解释 |
| 日志的去处 | 排障后归档 | 还能反哺知识库,成为下一轮检索的语料 |
| 采样 | 常按 1%~10% 采样 | 决策类日志不宜采样,采样会破坏可复现性 |
最后一行我想特别说一句。传统可观测性里采样是省钱利器,但在 AIOps 里,没记下来的那次决策,正好可能是出事的那次。我们的取舍是:性能类明细可以采样,决策类事件一条不落。
四、五个本质差异,不是「多记几个字段」
4.1 日志的对象从「事件」变成「决策」
传统日志的一条记录长这样:[INFO] POST /api/device 200 45ms。
| 智能运维需要的一条记录长这样:`run_id=x | phase=decided | evidence=chunk:0172, chunk:0410 | model=qwen2.5:7b@Q4 | params_sha=... | verdict="建议检查接口 BGP 配置" | actor_type=llm`。 |
前者回答「发生了什么」,后者回答「基于什么、得出了什么」。这不是字段多少的问题,是日志建模的对象变了:从"一次调用"变成"一次判断及其上下文"。
4.2 关联键从一条线变成一张图
trace_id 是树状的调用链。但 RAG/Agent 里的关联是网状的:
run_id(一次用户请求)
├── retrieval ──► evidence_ids(多条 chunk)
├── llm.call ──► prompt_sha(引用上面的 evidence)
├── tool.call ──► 入参 / 返回 / 下游设备
├── session ──► 回答全文 + citations
└── operation ──► op_id / 审批 / before-after / 回滚
一次问答可能触发多轮「规划 → 工具 → 观察」,还可能有重试和降级分支。所以除了 run_id / span_id,还得有 evidence_id(证据)、op_id(操作幂等键)、parent_span_id(重试前的那次)。光有 trace 不够,因为它不知道「这条证据喂给了哪次生成」。
4.3 可观测性三支柱在变形
Log / Metric / Trace 还在,但各多了一块:
- Log 多了 Decision Log (决策日志)与 Eval Log(质量评测)
- Metric 多了 Cost (token、调用次数)与 Quality(命中率、降级率、引用覆盖率)
- Trace 多了 Evidence Trace(证据到结论的映射)
我们会把「LLM 超时自动降级到规则兜底」单独记成一类,不计入通用失败率------因为它是有控回退,不是系统故障。否则监控面板天天红灯,真出事反而没人看。这类语义上的区分,传统日志体系里没有对应位置。
4.4 日志从成本变成资产
这是最容易被漏掉、也最有复利的一点。运维系统跑久了,日志里会沉淀出「某类告警 → 查了哪些证据 → 做了什么处置 → 结果如何」。把这些清洗成新的知识片段灌回向量库,下次同类问题,检索层就能直接给出历史处置经验。
传统系统里日志是"用完就归档的成本",这里它是喂给系统的训练/检索素材 。当然有前提:必须结构化、必须能按场景筛、必须脱敏。一堆自由文本的 print 是没法反哺的。
4.5 合规从「留痕」升级到「可复现 + 不可篡改」
内网和等保审计不会满足于「有日志」。他们会问:这条建议当时依据什么?谁批准的?日志有没有被改过?保留多久?
所以要做三件传统系统常省掉的事:决策上下文快照 、hash 链或只授 INSERT 的存储 、明确的保留与归档策略。尤其当系统开始给运维动作提供建议时,日志就是责任边界的凭据。
五、智能运维日志要具备的十个特性
逐条给要求、原因、做法和不做的后果。
1. 全程可关联
要求 :每个请求进门生成 run_id,所有下游(检索、模型、工具、会话、操作)自动带上;每步生成 span_id,重试/分支用 parent_span_id。
做法 :contextvars.ContextVar 隐式传递,别靠函数参数层层透传,也别用模块级全局变量。
不做的后果:回放时链路断在半路。我们真踩过两个坑,见第九节。
2. 决策可复现(五要素)
要求 :输入、证据、模型、提示词、输出,五样都能还原。
做法 :全文存会话,审计存摘要 + 内容哈希(prompt_sha / answer_sha);证据存 chunk id 列表而不只存文本。
不做的后果:被问「为什么这么答」时答不上来,系统就没法进生产。
3. 结构化 + 统一 schema
要求 :定义 AuditEvent,所有埋点走唯一入口 emit(),未知字段收进 extra。
不做的后果 :各接口随手写字段,前端只能靠正则猜「这个 api 是不是写操作」,后面所有检索、告警、报表都建不稳。我们的审计页早期就是这么干的,isWriteOp(api) 里躺着一串正则。
4. 只追加、不可篡改
要求 :模块层不提供删改接口;文件/表加 hash 链;独立账号只授 INSERT。
做法 :hash = sha256(prev_hash + canonical_json(record)),改任意一行,后续链全断。
不做的后果:出事之后日志本身成了可被质疑的对象。
5. 写失败不阻塞业务,但失败要可见
要求 :审计写入用 try/except 吞掉异常,磁盘满也不能让运维问答挂掉。但要计数并暴露到健康检查 。
不做的后果 :只 pass 不加计数的话,日志静默丢三天都没人知道------这是个很隐蔽的反模式,我们差点踩进去。
6. 人机责任可辨
要求 :actor_type(human / llm / system)+ actor_id + 建议者 suggested_by。
原则 :LLM 可以是建议者,绝不能是最终执行者 。
不做的后果:事故定责说不清。
7. 分级脱敏
| 要求:字段名命中 `password | secret | api_key | token` 一律掩码;正文里的 IP、MAC、手机号、工单号按规则替换;超长对象只留前 120 字 + 长度 + 哈希。 |
| 不做的后果:运维 query 里常带设备地址、账号、客户名,日志一落盘就是泄露面。 |
8. 分层留存与归档
要求 :热数据(近 30 天)本地快查,月度压缩归档,保留期按合规定(内网常见 ≥6 个月),超期走审批删除并记一条删除审计。
不做的后果:按天 JSONL 会无限增长,或者反过来,图省事删太早导致审计断档。
9. 可查询、可回放
要求 :replay(run_id) 是一等公民的查询,时间轴展示 started → 证据 → 模型 → 工具 → 回答 → 终态;支持按 actor / action / risk / 时间 / 关键词过滤与分页。
不做的后果:日志只是一堆文本,出事还得靠人肉 grep 拼。
10. 可度量
要求 :从日志能直接算出调用次数、token、p95、失败率、降级率 、引用覆盖率、中断率。
做法 :生命周期成对记录(started / completed / interrupted / failed),中断单独归类,不混进失败率。
不做的后果:没法回答「这套系统到底靠不靠谱」,也没法做容量规划。
六、怎么实现:一套能落地的分层架构
6.1 四层日志模型
| 层 | 内容 | 典型载体 | 特点 |
|---|---|---|---|
| L1 运行日志 | 启停、异常、资源、依赖健康 | stdout / 文件 | 高频、可采样、排障用 |
| L2 请求与决策日志 | 谁、什么动作、何时、生命周期 | 审计 JSONL / DB | 中频、不可采样、必带 run_id |
| L3 模型与工具日志 | 模型版本、参数、token、耗时、证据、工具入参与返回 | 调用明细 | 高频、数值型、用于成本与性能 |
| L4 变更与外部联动日志 | 预演、审批、执行前后差、设备动作、回滚 | 审计 + 快照 | 低频、最高价值、必须防篡改 |
分层不是为了好看,是因为四层的保留期、采样策略、查询方式、防篡改等级都不同,混在一个文件里必然互相拖累。
6.2 统一事件 schema
python
@dataclass
class AuditEvent:
ts: str; seq: int; run_id: str; action: str
phase: str = "" # started | previewed | pending | completed | failed | verified | rolled_back
span_id: str = ""; parent_span_id: str = ""
actor_type: str = "system" # human | llm | system
actor_id: str = ""
target: str = ""
risk: str = "low" # low | mid | high
verdict: str = "" # auto | review | reject
status: str = "ok"
duration_ms: int = 0
before_hash: str = ""; after_hash: str = ""
error: str = ""
extra: Dict[str, Any] = field(default_factory=dict)
prev_hash: str = ""; hash: str = ""
extra 是扩展位,但扩展位不等于随便写 :建议给常用语义约定固定键名(model / evidence / tool / tokens / op_id),否则又退化成猜字段。
一条真实形态的记录:
json
{
"ts": "2026-11-05T22:31:59.208", "seq": 3,
"run_id": "5fe5480e08e8", "action": "llm.call", "phase": "completed",
"span_id": "a1b2c3d4", "actor_type": "llm", "actor_id": "qwen2.5:7b-instruct",
"duration_ms": 18400, "status": "ok",
"extra": {
"model_version": "Q4_K_M",
"params": {"temperature": 0.2, "max_tokens": 512},
"prompt_tokens": 462, "completion_tokens": 49, "usage_reported": true,
"prompt_sha": "a5ac2cc449430267", "evidence": ["chunk:0172", "chunk:0410"]
},
"prev_hash": "5369660db9acd9a9", "hash": "e3f0a1..."
}
注意 usage_reported 这个字段。我们定过一条规矩:token 只累加服务端实际上报的值,没上报就记 0 并标 usage_reported=false,绝不按字符数估算。日志里出现编造的数字,比没有数字更坏------后面所有成本分析都会建立在假数据上。
6.3 关联 ID 怎么传
python
_run_ctx = contextvars.ContextVar("run_id", default="")
_span_ctx = contextvars.ContextVar("span_id", default="")
def _ensure_run():
rid = _run_ctx.get()
if not rid:
rid = uuid.uuid4().hex[:12]
_run_ctx.set(rid)
return rid
@contextmanager
def traced(action, target="", **kw):
explicit = kw.pop("run_id", "") # 请求边界显式新建
rid = explicit or _ensure_run() # 嵌套调用复用父级
tok_run = _run_ctx.set(rid) if explicit else None
parent = _span_ctx.get()
sid = uuid.uuid4().hex[:8]
tok_span = _span_ctx.set(sid)
emit(action, run_id=rid, span_id=sid, parent_span_id=parent,
target=target, phase="started", **kw)
t0 = time.monotonic()
try:
yield rid
except GeneratorExit:
emit(action, run_id=rid, phase="interrupted", status="interrupted")
raise
except Exception as e:
emit(action, run_id=rid, phase="failed", status="error", error=str(e)[:200])
raise
else:
emit(action, run_id=rid, phase="completed",
duration_ms=int((time.monotonic() - t0) * 1000))
finally:
_span_ctx.reset(tok_span)
if tok_run is not None:
_run_ctx.reset(tok_run) # 请求结束必须清干净
三个细节值得说:
- 进门就写
started。SSE 流式回答可能被用户中途关掉,如果只在结束时落盘,这次请求就凭空消失了。有了started,即使后面是interrupted,也能按run_id配出中断率。 GeneratorExit/CancelledError要单独接。它们是"用户不想等了",不是"系统挂了",混进失败率会污染告警。Python 3.8 起CancelledError继承BaseException,别指望except Exception接得住。- 请求边界必须 reset 。
contextvars在 asyncio 的 task 之间天然隔离,但同步脚本、线程池、或者框架复用 context 时不会。不清干净,下一个请求会继承上一个run_id。
6.4 写入链路
小规模下,同步 open(..., "a").write(json + "\n") 就够了,一次问答 2~4 条,开销可忽略。别一上来就上 Kafka。
什么时候换异步队列:单机日增超过十万条、或者写放大明显(每个 chunk 都埋点)、或者要多实例汇聚。换的时候要注意三点:
- 背压:队列满时丢性能类日志,保留决策类与审计类;不能无差别丢。
- 顺序:hash 链依赖顺序,多进程并发追加要么加文件锁,要么把链改成"按 run 分链",别让并发写把链搞断。
- 失败可见 :写失败计数进
/health,超过阈值告警。
6.5 存储三段式
| 阶段 | 存储 | 什么时候该迁 |
|---|---|---|
| 起步 | 按天 JSONL(audit/YYYY-MM-DD.jsonl) |
日增几千条,全文扫几十毫秒,够用 |
| 中期 | MySQL 分区表(按月),索引 (run_id, ts, action, actor_id) |
多实例、要 JOIN 会话与用量、要按复杂条件查、要独立只授 INSERT 的账号 |
| 长期 | 热表 30 天 + 冷归档(压缩 / 对象存储) | 合规留存 ≥6 个月 |
迁移别一刀切:emit() 双写一段时间 → 校验两边一致 → 切读新库 → 下线旧写入。我们的用量明细和审计是两套文件,靠 run_id JOIN,这个分工挺好用,迁库时也可以分别迁。
一个容量估算,心里有数:
单次问答日志量 ≈ 生命周期 2 条 + 检索 1 条 + 模型 1~2 条 + 工具 2~4 条 ≈ 6~9 条
单条平均 400 字节(长文本走哈希摘要后)≈ 3 KB/次
每天 1000 次问答 ≈ 3 MB/天 ≈ 1.1 GB/年(未压缩)
真正会把日志撑爆的从来不是问答,是把 prompt 全文、检索 chunk 全文、工具原始返回都塞进去。所以脱敏器里那条"超长对象只留摘要 + 哈希"不是优化,是必需。
6.6 防篡改
python
def _sha(obj):
raw = json.dumps(obj, ensure_ascii=False, sort_keys=True, default=str)
return hashlib.sha256(raw.encode("utf-8")).hexdigest()[:16]
# emit() 内
rec["prev_hash"] = _last_hash(path) # 从文件最后一行读,重启后链仍连续
rec["hash"] = _sha({k: v for k, v in rec.items() if k != "hash"})
sort_keys=True 很重要,否则 dict 顺序变化会让哈希对不上。校验时逐行重算,第一条不匹配的行号就是被改的位置。
分级建议:L0 只追加 + 文件权限;L1 加 hash 链;L2 同步写独立审计机 / 只授 INSERT 的库账号。大多数内网平台做到 L1 就够,别为了"绝对安全"把架构搞复杂------hash 链能发现篡改,但不能阻止篡改,这点要跟合规方讲清楚。
6.7 脱敏
python
SENSITIVE_KEYS = re.compile(r"password|secret|api_?key|token|authorization|credential", re.I)
def _desensitize(obj):
if isinstance(obj, dict):
return {k: ("***" if SENSITIVE_KEYS.search(str(k)) else _desensitize(v))
for k, v in obj.items()}
if isinstance(obj, list):
return [_desensitize(v) for v in obj[:20]]
if isinstance(obj, str):
s = re.sub(r"\b(?:\d{1,3}\.){3}\d{1,3}\b", "<ip>", obj)
if len(s) > 200:
return {"_clip": s[:120] + " ...", "_len": len(s), "_sha": _sha(s)}
return s
return obj
争议点在这:脱敏会损失可复现性(IP 变成 <ip> 就没法知道查的是哪台设备)。我的做法是审计日志脱敏,会话与快照原文另存并单独授权 ,两边靠 run_id 关联。想看原文走授权接口,访问动作本身再记一条审计。
6.8 回放查询
replay(run_id) 应该是产品里的一个页面,不是 DBA 的一条 SQL。时间轴上每一步展示:阶段、耗时、谁发起、证据 id、模型与参数、工具入参/结果、终态。前端从会话列表点「查看此次调用链」直接跳过来,排障效率差一个量级。
我们审计页早期只有「按天 / 按 api / 关键词过滤」的表格,排查一次"这回答为什么慢"要在三个 Tab 之间来回切。加回放视图之后,这类问题基本一屏定位。
6.9 日志 → 指标 → 告警
值得从日志里直接算的指标:
| 指标 | 从哪条日志来 | 为什么看它 |
|---|---|---|
| 调用次数 / token | llm.call |
成本与容量 |
| p95 延迟 | completed.duration_ms |
体验;CPU 上 8.5 tok/s 意味着 200 字要 15~20 秒 |
| 降级率 | phase=degraded / chat_rules |
模型是不是经常扛不住 |
| 中断率 | started 与 interrupted 按 run_id 配对 |
用户是不是等不及就关了 |
| 引用覆盖率 | 有 evidence 的回答占比 |
防幻觉的护栏有没有生效 |
| 审计写失败数 | emit() 计数 |
日志自己在不在丢 |
注意别把"有控降级"计成失败率。我们专门给规划超时回退规则开了独立计数,否则面板上失败率虚高,真故障反而被淹没。
6.10 日志反哺知识库
闭环长这样:
运行日志 → 按场景筛出「告警 / 证据 / 处置 / 结果」四元组
→ 人工抽检 + 脱敏 → 生成新的知识 chunk
→ 灌回向量库(重建 BM25 索引)
→ 下次同类问题被检索到
这一步我们还没做完,但方向是明确的:日志不是终点,是语料来源。前提是结构化做得够好,否则清洗成本会吃掉全部收益。
七、成本与性能:别让日志拖垮主链路
几条实测经验:
慢要分开记。 纯 CPU 上一次生成 9.7 秒,其中冷加载 5.85 秒、prefill 0.54 秒、decode 3.29 秒。如果只记一个 total_duration,后面查"为什么这么慢"完全没抓手。模型侧的四段耗时(load / prompt_eval / eval / total)应该原样收进 extra。
SSE 流式下别逐 token 写日志。 进门写 started,结束写 completed,中间只累加。逐 token 落盘会把 I/O 放大几百倍,而信息量没增加。
全文与摘要分家。 回答全文存会话(本来就要存),审计存 answer_sha + 前 120 字。这样审计文件小、可校验、不重复占空间。
采样要分类型。 L1 运行日志可以 10% 采样;L2/L3 决策与模型调用不采样;L4 变更日志一条不落,且单独存。一句判断标准:采样不能牺牲任何一次决策的可复现性。
别在关键路径做同步富化。 证据全文、设备名解析这类"查一下再写进日志"的动作,放后台补,别卡在用户等回答的那条链上。
八、和传统可观测体系的关系:不是推翻,是叠加
ELK / Loki / OpenTelemetry 照样用,它们解决的是收集、检索、调用链这些通用问题。要补的是语义层:
- OpenTelemetry 的 span 记的是"调了什么",不记"喂了哪几条证据、为什么选这个工具"→ 在 span attribute 里加
evidence_ids/model_version/decision_reason - 日志平台的字段映射要按
AuditEvent建,别让各服务自由发挥 - 传统 APM 的告警规则要加上"降级率 / 引用覆盖率"这类新指标
换句话说:传统那套是地基,智能运维多出来的是上面一层「决策语义」。地基不用重打,但别指望地基自己长出这一层。
什么时候不该上这套?系统里没有 LLM 参与判断、纯只读问答没有写动作、日志只用于排障、团队规模小到没人会去做复盘------这时候传统日志加结构化 JSON 就够了,硬上决策日志只是给自己加维护负担。
九、实测踩过的坑(这部分最值得抄)
-
token 不要估算。 服务端没上报 usage 就记 0 并标注,别按字符数除 4 估一个。假数据比没数据更危险。
-
共享诊断对象会串味。 我们曾用单例
_last_diag记录本次检索的耗时,并发请求下 A 的耗时会被 B 覆盖。改成每次调用传当次实例,或者用contextvars。这和日志关联 ID 是同一类问题。 -
嵌套调用重新生成 run_id,子步骤从回放里消失。 写 demo 时第一版就是这么错的,
traced()里写成or new_run(),结果工具调用那三步整段不见。修法是顶层显式新建、嵌套复用父级。 -
请求边界不 reset,下一个请求继承上一个 run_id。 第二版 demo 又踩一次:第一次问答结束后 context 里还留着
run_id,后面一次变更操作的回放里混进了整段问答记录。同步脚本里一眼能看出来,异步框架里会更隐蔽。 -
写失败只
pass不够。 磁盘满时日志静默丢三天,健康检查全绿。要有计数、有告警。 -
schema 不统一,前端只能靠正则猜。
isWriteOp(api)里堆了一串reload|reindex|set-active|...,每加一个接口就要补一条正则,迟早漏。 -
分数记了但展示没意义。 混合检索的 RRF 名次分是
1/(60+rank)量级,五条命中分别是 0.0328 / 0.0304 / 0.0294...,前端toFixed(2)一抹全变成「相关度 0.03」。日志里记了原始分,产品上却给了用户一个假指标。后来改成「来源徽章 + 名次」。记对不等于用对。 -
中文截断别用
textwrap.shorten。 它按空白分词,整段中文会被当成一个超长词,截完只剩标题几个字,日志里的摘要因此残缺。按字符数切。 -
配置变更要记 before/after。 我们有个模型注册逻辑,同类型第一个模型会自动设为默认并激活。没记 before 的话,"谁把全局模型换了"这个问题根本查不出来。
-
冷加载要单独计。 模型 10 分钟不用就卸载,首问多出 5.85 秒。不记加载耗时,就会误判成"模型突然变慢了"。
第 3、4 条是这次为了写文章跑 demo 时新踩的,代码和修复都留在配套脚本里。我觉得这比讲十页设计原则更有用:关联 ID 这东西,看代码都觉得简单,跑一次才知道边界在哪。
十、几个我没定论、想拿出来讨论的问题
写清楚自己没想明白的地方,比假装全都有答案更有价值。
1. prompt 全文该不该落盘?
落了,可复现性最好,但可能含设备地址、账号、客户信息,以及用户随口输入的敏感内容;不落,只留哈希,能校验"内容没被改",但没法重放当时的上下文。我们现在的折中是全文存会话并单独授权、审计只留哈希。想听听有合规要求的团队怎么做------尤其是日志要跨团队共享的场景。
2. 保留期按时间还是按价值?
一刀切 6 个月,成本高;按"是否触发过告警 / 是否被人工复核过 / 是否是变更操作"分级保留,逻辑更合理但实现复杂,还容易在事后被质疑"为什么这条没留"。有没有成熟的分级留存实践?
3. 防篡改做到哪一级够?
hash 链能发现篡改,不能阻止;独立审计机 + 只授 INSERT 能阻止,但要多一套设施和账号体系。内网平台里,L1 是不是真的够?还是说只要系统开始给运维动作提建议,就必须上 L2?
4. 日志能不能直接当评测集?
把历史问答(query + 证据 + 回答)拿来跑检索命中率评测很方便,但语料会随知识库更新漂移,历史"正确答案"可能已经过期。要不要给每条日志打上知识库版本快照?打了之后存储成本又上去了。
5. LLM 输出算不算审计证据?
它不可靠、可能含幻觉,但确实是"系统当时说了什么"的事实。我们的处理是:输出照存,但明确标注 actor_type=llm,且必须配 evidence,让看日志的人一眼分清「模型说的」和「有依据的」。这个约定会不会在事故复盘时仍被误读?
6. 采样和可复现怎么共存?
性能类明细采样没问题,但如果某个工具调用被采样掉了,回放链就缺一环。是不是应该保证"每次请求至少留完整骨架,明细才允许采样"?这条界线具体画在哪,我还没找到一个能写进规范的判据。
十一、最小可运行实现
配套脚本 aiops-logging-demo.py(无第三方依赖,Python 3.8+)实现了:AuditEvent / emit() / traced() / replay() / verify_chain() / 脱敏器。日志写系统临时目录,不污染项目数据。
python3 docs/aiops-logging-demo.py
实测输出(节选):
[1] 一次问答的回放(run_id = f43d7fbed0e1)
started chat | by human:zengjian
completed retrieval | evidence=['chunk:0172', 'chunk:0410', 'chunk:0615']
completed llm.call | 18400ms | by llm:qwen2.5:7b-instruct | model=qwen2.5:7b-instruct
started tool.call | tool=ping
completed tool.result
completed tool.call | 7ms
completed chat | 39ms
[2] 一次失败变更的回放(run_id = 746cb3ea930c)
started config.update | by human:ops02
previewed operation.preview
failed config.update | 17ms | ERR=回读校验不通过:接口状态未生效
[3] 脱敏效果
敏感字段 password -> ***
检索词里的 IP -> CE6881 BGP 配置 <ip>
超长 answer -> {'_clip': '根据《CE6881 配置手册》第 3 章...', '_len': 288, '_sha': 'a5ac2cc449430267'}
[4] hash 链校验
原始状态 -> 完好
篡改第 4 行后 -> 在第 4 行发现断链
四段分别对应:全程关联、失败也有终态、脱敏与摘要、防篡改。这四件事凑齐,日志才算从"记录"变成"可追溯"。
十二、落地检查清单
| # | 检查项 |
|---|---|
| 1 | 所有入口都有 run_id,嵌套调用不另起链,请求结束 reset |
| 2 | 进门写 started,异常写 failed,中断写 interrupted,三者不混 |
| 3 | 决策五要素齐:输入、证据、模型版本、prompt 哈希、输出哈希 |
| 4 | 所有埋点走 emit(),没有散落的 logger.info(json) |
| 5 | 审计写入不阻塞业务,且写失败有计数与告警 |
| 6 | actor_type 能区分人 / LLM / 系统,LLM 不是最终执行者 |
| 7 | 敏感字段、IP、超长对象都过脱敏器 |
| 8 | hash 链可校验,篡改能被发现 |
| 9 | 保留期、归档、超期删除有明确策略,删除动作本身留痕 |
| 10 | 产品里有 replay(run_id) 页面,不靠 grep 拼链路 |
| 11 | 降级率、中断率、引用覆盖率能从日志算出来 |
| 12 | 模型耗时拆到 load / prefill / decode |
| 13 | 写操作有 before/after 与 op_id 幂等键 |
| 14 | 冒烟用例覆盖:断链、并发串味、写失败、篡改、超长文本 |
| 15 | 日志反哺知识库的通路有清洗与脱敏环节 |
结语
传统日志回答的是「系统做了什么」。系统开始自己做判断之后,我们要回答的是「它凭什么这么做、谁让它做的、做错了能不能说清楚、能不能收回来」。
这两句话之间的距离,就是 run_id、证据快照、统一 schema、hash 链、回放页面这一整套东西。听起来像加了不少活,但真正动手时只需要抓住两个点:把埋点收进一个 emit(),把每个请求串进一条 run_id。剩下的分层、归档、防篡改、反哺语料,都是在这两件事之上按需要长出来的,可以一步步加。
我最想留下的经验其实是第九节那两条断链坑。设计文档里写「用 contextvars 传 ID」只有一行字,跑起来才知道请求边界和嵌套调用各有一个坑在等。日志系统的可靠性不是设计出来的,是被真实请求和几次失败回放逼出来的。
如果你们也在做类似平台,第十节那六个问题,欢迎拍砖。