当系统开始自己做判断:智能运维里的日志,为什么不能照搬传统那套

一份关于日志实现方式、必备特性与落地取舍的技术分析

面向做 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 都埋点)、或者要多实例汇聚。换的时候要注意三点:

  1. 背压:队列满时丢性能类日志,保留决策类与审计类;不能无差别丢。
  2. 顺序:hash 链依赖顺序,多进程并发追加要么加文件锁,要么把链改成"按 run 分链",别让并发写把链搞断。
  3. 失败可见 :写失败计数进 /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 就够了,硬上决策日志只是给自己加维护负担。


九、实测踩过的坑(这部分最值得抄)

  1. token 不要估算。 服务端没上报 usage 就记 0 并标注,别按字符数除 4 估一个。假数据比没数据更危险。

  2. 共享诊断对象会串味。 我们曾用单例 _last_diag 记录本次检索的耗时,并发请求下 A 的耗时会被 B 覆盖。改成每次调用传当次实例,或者用 contextvars。这和日志关联 ID 是同一类问题。

  3. 嵌套调用重新生成 run_id,子步骤从回放里消失。 写 demo 时第一版就是这么错的,traced() 里写成 or new_run(),结果工具调用那三步整段不见。修法是顶层显式新建、嵌套复用父级。

  4. 请求边界不 reset,下一个请求继承上一个 run_id。 第二版 demo 又踩一次:第一次问答结束后 context 里还留着 run_id,后面一次变更操作的回放里混进了整段问答记录。同步脚本里一眼能看出来,异步框架里会更隐蔽。

  5. 写失败只 pass 不够。 磁盘满时日志静默丢三天,健康检查全绿。要有计数、有告警。

  6. schema 不统一,前端只能靠正则猜。 isWriteOp(api) 里堆了一串 reload|reindex|set-active|...,每加一个接口就要补一条正则,迟早漏。

  7. 分数记了但展示没意义。 混合检索的 RRF 名次分是 1/(60+rank) 量级,五条命中分别是 0.0328 / 0.0304 / 0.0294...,前端 toFixed(2) 一抹全变成「相关度 0.03」。日志里记了原始分,产品上却给了用户一个假指标。后来改成「来源徽章 + 名次」。记对不等于用对。

  8. 中文截断别用 textwrap.shorten。 它按空白分词,整段中文会被当成一个超长词,截完只剩标题几个字,日志里的摘要因此残缺。按字符数切。

  9. 配置变更要记 before/after。 我们有个模型注册逻辑,同类型第一个模型会自动设为默认并激活。没记 before 的话,"谁把全局模型换了"这个问题根本查不出来。

  10. 冷加载要单独计。 模型 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」只有一行字,跑起来才知道请求边界和嵌套调用各有一个坑在等。日志系统的可靠性不是设计出来的,是被真实请求和几次失败回放逼出来的。

如果你们也在做类似平台,第十节那六个问题,欢迎拍砖。

相关推荐
nice_lcj5202 小时前
【从零理解 Spring AI · 03】Spring AI 在 Spring 生态里的位置
java·spring boot·大模型·spring ai·ai应用开发
组工部管理能手李哥17 小时前
一次政务知识库体验:AI回答之外,我更在意那行引用来源
大数据·人工智能·大模型·政务
Flynn Liang1 天前
AgentScope Java 深度解析:ReActAgent、HarnessAgent 与多智能体编排——JVM 上的自治 Agent 底座
java·大模型·架构设计·ai agent·spring ai·langchain4j·agentscope
香吧香1 天前
彻底理解RRF混合检索的实现机制和流程
大模型
隔窗听雨眠1 天前
大模型强化学习训练的工程实现与调优路径
大模型·强化学习·llm训练
codigger1 天前
2026年,AI PC靠什么反超传统PC?
ai·大模型·编程语言
Flynn Liang2 天前
Spring AI 2.0 深度解析:ChatClient、Advisor 链与 MCP——Java 工程师的 AI 落地最优解
java·人工智能·spring·大模型·架构设计·spring ai
DevOps老兵2 天前
Transformer拆解06:Self-Attention,一句话里的词怎样互相看见
大模型·transformer·自注意力·self-attention·注意力矩阵
我喜欢就喜欢2 天前
RAG 重排序 Rerank 实战:用 bge-reranker 把 Top-K 命中率拉回来
python·大模型·向量检索·rag·rerank