好的,我会严格遵循所有要求和约束,为您撰写一篇可直接发布到CSDN的技术博文。以下是正文内容。
Charity Majors 谈 AI、确定性与可观测性:为什么“吃下蔬菜”才是 AI 工程的关键
如果你正在做 LLM 应用开发,或者正在把 AI 能力接入自己的系统,最近肯定绕不开一个问题:AI 应用上线之后,怎么排查问题?
传统后端出问题,看一眼日志堆栈、查一下链路追踪,几分钟就能定位。但换成 AI 应用之后,这套经验突然失灵了。你面对的是一个“每次生成结果都可能不同”的黑盒。请求没有崩溃,接口返回了 200,但用户的反馈是“回答质量变差了”“偶尔会胡说八道”“同一句话问两次,结果不一样”。
这类问题靠监控 CPU、内存、接口延时根本发现不了,因为它们不是“宕机”级别的问题,而是“质量漂移”和“行为非确定性”带来的隐性故障。
最近读了 Charity Majors 关于 AI、确定性(Determinism)与可观测性(Instrumentation)的一些分享,她有一个非常直接的观点:AI 系统确实带来了新的挑战,但很多人试图跳过那些“基础但枯燥”的工程步骤,想用更炫的方案解决 AI 问题,这条路是走不通的。她把这些基础工程工作称为“吃你的蔬菜”(Eating Your Broccoli)——不好吃,但必须吃。
这篇文章我想围绕她的核心判断,结合 AI 应用工程化的实际场景,聊清楚三个问题:
- 为什么 AI 应用让传统监控手段失效?
- 确定性与非确定性,在 AI 工程里到底意味着什么?
- 可观测性如何在 AI 应用里真正落地?
文章会包含可操作的代码示例、排查思路和工程建议。如果你正在把 AI Agent、RAG 或大模型 API 集成到生产环境,这篇文章应该能帮你少踩一些坑。
1. 这篇文章真正要解决的问题
先给一个明确判断:AI 应用开发最大的挑战不是“模型能力不够”,而是“工程化能力跟不上”。
很多团队从 Demo 到生产只差一步,但就是这一步迈不过去。原因不是模型选型不对,而是出了问题根本不知道去哪查。
传统软件工程里,系统的行为是可预测的。输入相同,输出必然相同;出了问题,可以通过复现来排查。LLM 应用恰恰相反,它的核心组件是概率模型,同样的 prompt 在不同时间、不同参数下可能产生完全不同的输出。这种不确定性让“复现 bug”这个最基本的排查手段失效了。
更麻烦的是,AI 应用不是只有模型推理这一层。一个典型的 RAG 应用牵扯到:
- 文档解析与分块
- 向量化与召回
- Prompt 模板与上下文组装
- 模型推理参数(temperature、top_p 等)
- 输出解析与后处理
- 外部工具调用(Agent 场景)
每一层都可能出错,而且错误是逐层放大的。文档分块切错了,召回质量就差;上下文塞得太满,模型就抓不住重点;输出解析正则写错了,前端拿到的就是空壳。这些问题在开发和测试环境里很难触发,因为测试集太小,数据太干净。
Charity Majors 的观点很直接:与其把希望寄托在“更好的模型”或“更聪明的框架”上,不如先把最基础的工程能力补齐——测试、日志、追踪、指标、数据质量。这些工作看着枯燥,但决定了你的 AI 应用能不能长期稳定运行。
这篇文章适合的人群很明确:
- 正在把 LLM 能力接入生产系统的后端工程师;
- 已经在做 AI Agent 或 RAG 应用,但被线上问题困扰的开发者;
- 负责 AI 应用稳定性、质量和用户体验的技术负责人。
读完你会理解为什么“给 AI 应用加监控”这件事的逻辑跟传统监控不一样,也会拿到一套可以直接用的思路和代码片段。
2. AI 应用的可观测性为什么这么难:确定性系统与非确定性系统
要讲清楚可观测性,先得从“确定性”说起。这是整个议题的核心,也是 AI 工程和传统工程最本质的分水岭。
2.1 确定性系统:你习惯的那套调试方式
传统后端系统,比如一个典型的 Web 服务,本质上是确定性系统:
- 给定同样的输入参数;
- 经过相同版本的代码逻辑;
- 在相同的环境下运行;
- 输出结果几乎一定是相同的。
所以当用户报障时,你可以在测试环境复现,可以加日志、加断点,一步步缩小范围。这种“复现-定位-修复-验证”的模式,是大部分工程师的看家本领。
传统监控体系也是围绕确定性系统设计的:
- 你关注 QPS、错误率、响应时间,因为系统的行为曲线是可预测的;
- 异常意味着“偏离了基线”;
- 报警阈值可以预先设定,因为你知道什么是“正常”。
这套体系在确定性系统里非常有效。
2.2 非确定性系统:AI 引入了什么变量
到了 LLM 应用,情况变了。模型本身是概率系统,它的输出天然带有随机性。即便 temperature 设成 0,模型推理过程中的采样策略、上下文窗口中的微小变化,也可能导致输出不同。
但这还不是最麻烦的。真正让可观测性变难的是,AI 应用的不确定性不只是模型层带来的,而是整个链路叠加出来的:
| 层级 | 不确定性来源 | 传统系统是否有 |
|---|---|---|
| 数据层 | 向量库召回结果随文档更新变化 | 无 |
| 检索层 | 相似度排序受 embedding 影响 | 无 |
| Prompt 层 | 模板微调、上下文长度变化 | 无 |
| 模型层 | 采样随机性、模型版本更新 | 无 |
| 工具层 | Agent 调用外部 API 返回不稳定 | 少数 |
| 后处理层 | 输出解析对格式敏感 | 少数 |
一层的不确定性可能还可以接受,但当多层叠加时,问题就变得极其复杂。你很难判断一次“回答质量下降”到底是哪一层出的问题。
Charity Majors 提到的核心问题在这里:非确定性系统不是不需要可观测性,而是比确定性系统更需要可观测性,只不过要观测的东西变了。
在确定性系统里,你要观测的是“系统是否偏离了预期行为”。在非确定性系统里,你要观测的是“系统在不同输入、不同上下文下的行为分布”。前者看曲线,后者看分布。
2.3 一个比喻:导航系统
打个比方。传统后端系统就像一条固定轨道的地铁:线路固定,时刻表固定,出问题只需要查是哪一站、哪一趟车。
LLM 应用更像地图 App 的实时导航:它会根据实时路况不断调整路线,到达时间是一个预估区间。你不能问“为什么它这次走了这条路、上次走了那条路”——每次路况都不一样。但你可以观测的是:导航是否始终在朝着目的地前进?途中是否有绕路?用户重新规划路线的频率是不是变高了?
这就是 AI 可观测性的核心思路:不追求“精确复现”,而是理解“行为分布”和“质量趋势”。
3. 可观测性不等于监控:Instrumentation 到底是什么
很多团队说“我们要给 AI 应用上可观测性”,实际做的是加了几张 Dashboard,看 QPS、错误率和延迟。这不是可观测性,这只是在原来监控面板上加了几个新指标。
3.1 监控(Monitoring)与可观测性(Observability)的区别
根据 Charity Majors 一贯强调的分类,两者有一个经典对比:
- 监控:你知道可能出现什么问题,提前设定阈值,问题发生时报警。它的前提是“已知的未知”。
- 可观测性:你不知道会出现什么问题,但通过系统暴露的丰富数据,你可以在事后探索、定位,找出当初没预料到的“未知的未知”。
AI 应用里的大多数严重问题,恰恰属于“未知的未知”。比如:
- Prompt 模板里一个标点符号的变化,导致大范围回答质量下降;
- 向量库更新后,某些类别的问题召回率骤降;
- 模型供应商更新了底层模型版本,虽然 API 没变,但风格大变;
- 用户以某种特定方式提问时,Agent 会陷入死循环调用工具。
这些问题不可能通过预设阈值发现。你必须让系统产生足够丰富、结构良好、可探索的数据,才能在事后大海捞针。
3.2 Instrumentation:让你获得探索数据的能力
“Instrumentation”这个词在中文里常被翻译为“埋点”“插桩”或“检测”,但在可观测性语境下,它更准确的含义是:让系统把内部状态和行为以可查询的形式暴露出来。
对于 AI 应用,Instrumentation 不是简单地在日志里加几行 print,而是要做到:
- 记录每次请求的完整上下文:用户输入、检索到的文档、最终进入模型的 Prompt、模型输出、后处理结果。
- 保留高基数属性:用户 ID、会话 ID、模型版本、Prompt 版本、向量库版本、检索文档 ID 等。这些属性每个值都不同,无法预聚合,但正是后续排查的关键索引。
- 建立请求链路:一次用户请求可能内部调用了多次模型推理、多次向量检索、多次工具调用,必须把这些步骤串联起来。
- 输出结构化事件:不是“字符串拼接日志”,而是带字段的 JSON 结构,方便后续分析。
从工程实践来看,这意味着两件事:一是你的应用代码需要做好结构化日志和追踪;二是你需要一个能存储和查询高基数数据的后端系统。
3.3 AI 可观测性要回答的四类问题
把 Instrumentation 做好了,你应该能回答以下四类问题:
| 问题类型 | 具体示例 | 需要的数据 |
|---|---|---|
| 发生了什么 | 哪些请求回答质量差 | 日志、质量评分 |
| 为什么发生 | 是不是检索召回失败 | 上下文、检索结果、模型输出 |
| 影响范围多大 | 是某个用户、某个 prompt 版本还是所有流量 | 高基数属性 |
| 趋势如何变化 | 模型升级后回答风格是否漂移 | 时间序列、属性分布 |
这四类问题的回答难度依次递增,但它们都依赖同一件事:从第一行代码开始,就做好 Instrumentation。
4. AI 工程里的“确定性测试”:让系统至少在某些层面可预测
有些人可能会问:既然 AI 系统是非确定性的,那“测试”还有意义吗?测试的基本假设不就是“输入相同,输出可预期”吗?
Charity Majors 在讨论中反复强调一个观点:AI 系统确实非确定,但你不能因此放弃确定性测试。恰恰相反,你要把能确定的东西全部确定下来,把不确定性限制在尽可能小的范围内。
这就引出了“确定性测试”的实践思路。
4.1 哪些层可以做成确定性测试
AI 应用虽然整体是非确定性的,但链路里有很多环节其实可以做成确定性测试:
第一层:纯逻辑层。文档解析、文本分块、JSON 解析、后处理逻辑、工具调用的参数组装,这些都是纯代码逻辑,输入输出完全可预期。这一层没有任何借口不测试。
第二层:输出约束层。你可以通过 prompt 约束模型输出格式(比如 JSON),并在代码里做严格校验。虽然模型输出本身非确定,但你可以断言“输出必须是合法 JSON”“必含字段必须存在”。
第三层:行为分布层。对于模型输出本身,不追求精确断言,而是断言“多次运行的行为分布符合预期”。比如,在一个分类任务里,10 次运行中至少 8 次返回同一个类别。
现实中很多团队跳过了前两层,直接对模型输出做模糊断言,甚至完全不做测试。结果就是:改了一个分块逻辑,不确定坏了什么;改了一个 Prompt 模板,不确定影响了哪些场景。
4.2 用测试用例锁定“可预测部分”
这里的关键原则是:把能锁死的东西全部锁死,把不能锁死的东西用分布去描述。
举个例子。在做 RAG 应用时,向量检索结果的质量可以用“黄金数据集”来做确定性回归测试。你准备一组标准的 query-document 配对,每次调整 embedding 模型、分块策略或向量库配置时,都跑一遍这组数据,看召回率是否下降。这个过程是确定性的:同样的 query、同样的向量库、同样的检索参数,召回结果必然相同。
有了这层保障,你才敢放心去调整那些真正非确定的部分——模型参数、Prompt 设计。
4.3 用“行为测试”覆盖非确定性部分
对于真正非确定的模型输出,可以利用 LLM 作为评测器(LLM-as-a-judge)来做行为测试。这部分测试不断言精确输出,而是断言“输出质量是否达标”。
例如,你可以设置一个测试:输入一组标准问题,让模型回答,然后让另一个 LLM 打分(或者用规则检查),断言分数超过阈值。这种测试虽然每次结果可能略有浮动,但它能捕捉到严重的回归——比如 Prompt 改坏了、上下文拼接错误、召回内容不相关。
把这两类测试结合起来:
- 黄金数据集召回测试 → 覆盖检索层;
- 纯逻辑单元测试 → 覆盖解析、后处理层;
- LLM-as-a-judge 行为测试 → 覆盖模型生成层;
- 输出 schema 校验 → 覆盖接口层。
这样测试的覆盖范围就远大于只测模型输出的做法。
5. 完整示例:LLM 应用的可观测性 Instrumentation 实践
理论讲了不少,接下来进入实操。下面我用一个典型的 RAG 应用简化版来演示,如何从代码层面做好 AI 应用的可观测性。
5.1 场景说明
假设我们构建一个“内部知识库问答助手”,用户提问后,系统先检索向量库中的相关文档片段,再拼接 Prompt 调用 LLM,最后返回答案。这是最常见的 RAG 应用形态。
我们的目标是:每次请求都产出结构化的、包含完整上下文的追踪数据,方便事后排查“为什么这个回答质量差”。
5.2 代码示例:结构化事件记录
首先实现一个简单的结构化日志模块。这里使用 Python 的logging+ JSON 格式化,生产环境可以替换为 OpenTelemetry 或其他 tracing 框架。
# 文件路径:app/observability.py import json import logging import time import uuid from contextvars import ContextVar request_id_var: ContextVar[str] = ContextVar("request_id", default="unknown") class JsonFormatter(logging.Formatter): """将日志记录格式化为 JSON,便于后续采集与分析。""" def format(self, record: logging.LogRecord) -> str: log_entry = { "timestamp": time.strftime("%Y-%m-%dT%H:%M:%S%z", time.localtime(record.created)), "level": record.levelname, "logger": record.name, "request_id": request_id_var.get(), "message": record.getMessage(), } # 允许通过 extra 字段注入自定义属性 if hasattr(record, "props"): log_entry.update(record.props) return json.dumps(log_entry, ensure_ascii=False) def setup_logging(): handler = logging.StreamHandler() handler.setFormatter(JsonFormatter()) root_logger = logging.getLogger() root_logger.handlers = [handler] root_logger.setLevel(logging.INFO) def log_event(logger: logging.Logger, event: str, **props): """打印结构化事件,自动附带 request_id。""" logger.info(event, extra={"props": props})核心思路是:所有日志自动携带request_id,事件的细节通过props字典以字段形式记录,而不是拼在 message 字符串里。这样后续就可以在日志系统里按request_id快速聚合某个请求的全部事件。
5.3 代码示例:RAG 链路的完整埋点
接下来是 RAG 的核心流程。每次请求,我们会记录检索阶段和生成阶段的完整上下文。
# 文件路径:app/rag_service.py import logging import uuid import time from app.observability import log_event, request_id_var logger = logging.getLogger(__name__) class MockVectorStore: """简化的向量库客户端,用于演示。""" def search(self, query: str, top_k: int = 3): # 实际项目中替换为真实的向量检索调用 return [ {"doc_id": "doc-001", "score": 0.91, "content": "可观测性是指系统内部状态的可探索能力。"}, {"doc_id": "doc-002", "score": 0.85, "content": "现代可观测性基于高基数数据。"}, {"doc_id": "doc-003", "score": 0.72, "content": "传统监控与可观测性有本质区别。"}, ][:top_k] class MockLLM: """简化的 LLM 客户端,用于演示。""" def generate(self, prompt: str, temperature: float = 0.2): # 实际项目中替换为真实的大模型调用 return "可观测性关注系统内部状态,而监控关注已知问题。" class RagService: def __init__(self): self.vector_store = MockVectorStore() self.llm = MockLLM() def answer(self, user_query: str, trace_context: dict | None = None): request_id = uuid.uuid4().hex request_id_var.set(request_id) start_time = time.time() log_event(logger, "request_start", request_id=request_id, user_query=user_query, trace_context=trace_context) # 阶段一:检索 retrieval_start = time.time() docs = self.vector_store.search(user_query, top_k=3) retrieval_latency_ms = (time.time() - retrieval_start) * 1000 log_event(logger, "retrieval_completed", request_id=request_id, retrieval_latency_ms=retrieval_latency_ms, doc_ids=[d["doc_id"] for d in docs], scores=[d["score"] for d in docs]) # 阶段二:组装 Prompt context = "\n\n".join([d["content"] for d in docs]) prompt = f"基于以下资料回答问题:\n\n{context}\n\n问题:{user_query}" log_event(logger, "prompt_assembled", request_id=request_id, prompt_template_version="v1.2", context_char_count=len(context), context_doc_count=len(docs)) # 阶段三:调用模型 llm_start = time.time() answer_text = self.llm.generate(prompt, temperature=0.2) llm_latency_ms = (time.time() - llm_start) * 1000 log_event(logger, "llm_completed", request_id=request_id, llm_latency_ms=llm_latency_ms, # 注意:完整记录 prompt 和 answer 会有数据成本, # 实际生产环境请评估内容安全与隐私合规要求。 answer_length=len(answer_text)) # 阶段四:输出清洗 cleaned_answer = answer_text.strip() total_latency_ms = (time.time() - start_time) * 1000 log_event(logger, "request_completed", request_id=request_id, total_latency_ms=total_latency_ms) return {"request_id": request_id, "answer": cleaned_answer}这段代码演示了几个关键实践:
按阶段记录事件:
request_start、retrieval_completed、prompt_assembled、llm_completed、request_completed,事件命名清晰,后续可以在日志系统里直接按事件类型查询。每个事件携带高基数属性:
request_id、doc_ids、scores、prompt_template_version、latency_ms。这些字段每个请求都不同,传统监控体系不会去聚合它们,但正是在排查问题时最有价值的索引。不记录敏感内容:注释里明确提示,完整 Prompt 和 answer 的记录可能涉及隐私和内容安全。生产环境应根据合规要求决定是否记录,或者截断后记录。
5.4 代码示例:运行时追踪上下文传递
上面的示例用ContextVar传递request_id,这只适用于单线程异步场景。如果应用中有子任务并行执行,或者调用下游服务,需要引入真正的链路追踪。
下面给出一个更通用的 OpenTelemetry 式封装思路,不需要完整引入框架,但足以说明追踪上下文如何在调用链中传递。
# 文件路径:app/tracing.py import contextlib import uuid import json from dataclasses import dataclass, field from typing import Optional @dataclass class Span: trace_id: str span_id: str parent_span_id: Optional[str] name: str attributes: dict = field(default_factory=dict) start_time: float = 0.0 end_time: float = 0.0 def to_dict(self): return { "trace_id": self.trace_id, "span_id": self.span_id, "parent_span_id": self.parent_span_id, "name": self.name, "attributes": self.attributes, "duration_ms": (self.end_time - self.start_time) * 1000, } class Tracer: """极简的基于 span 的追踪器,用于演示 span 概念。 生产环境建议使用 OpenTelemetry SDK 并接入 Collector。 """ def __init__(self): self.spans: list[Span] = [] @contextlib.contextmanager def start_span(self, name: str, trace_id: Optional[str] = None, parent_span_id: Optional[str] = None, **attributes): tid = trace_id or uuid.uuid4().hex sid = uuid.uuid4().hex span = Span(trace_id=tid, span_id=sid, parent_span_id=parent_span_id, name=name, attributes=attributes) span.start_time = __import__("time").time() try: yield span finally: span.end_time = __import__("time").time() self.spans.append(span) print(json.dumps(span.to_dict(), ensure_ascii=False))这个简化版 Tracer 的意图不是让你在生产环境自己实现 tracing,而是帮你理解 span 的核心概念:trace_id串联整条链路,parent_span_id表达嵌套关系,attributes记录关键字段。生产环境直接用 OpenTelemetry 就好。
5.5 如何运行与验证
在实际的 Demo 项目里,你可以像下面这样调用:
# 文件路径:app/main.py import logging from app.observability import setup_logging from app.rag_service import RagService setup_logging() service = RagService() result = service.answer("什么是可观测性?", trace_context={"user_id": "u-12345"}) print("最终回答:", result["answer"])运行后,标准输出会按时间顺序输出 JSON 格式的事件,每个事件都带同一个request_id。接着在日志系统里执行一次按request_id的搜索,就能看到这个请求从开始到结束的完整链路。
验证成功的标准很简单:
- 五个阶段的事件是否都出现了;
- 同一个
request_id是否贯穿所有事件; - 每个事件是否包含足够的高基数属性(doc_ids、scores、latency 等);
- 把输出交给一个不懂代码的人,他是否能根据日志复述这个请求经历了哪些阶段。
如果以上都满足,说明你的 Instrumentation 已经达到可以排查问题的级别。
6. 运行效果与可观测性数据的使用方式
6.1 一次请求的日志输出示例
上面的代码运行后,日志输出大致如下(具体字段值根据运行环境会不同):
{"timestamp": "2025-01-12T10:24:31+0800", "level": "INFO", "logger": "app.rag_service", "request_id": "3f2a1b9c...", "message": "request_start", "user_query": "什么是可观测性?", "trace_context": {"user_id": "u-12345"}} {"timestamp": "2025-01-12T10:24:31+0800", "level": "INFO", "logger": "app.rag_service", "request_id": "3f2a1b9c...", "message": "retrieval_completed", "retrieval_latency_ms": 12.3, "doc_ids": ["doc-001", "doc-002", "doc-003"], "scores": [0.91, 0.85, 0.72]} {"timestamp": "2025-01-12T10:24:31+0800", "level": "INFO", "logger": "app.rag_service", "request_id": "3f2a1b9c...", "message": "prompt_assembled", "prompt_template_version": "v1.2", "context_char_count": 156, "context_doc_count": 3} {"timestamp": "2025-01-12T10:24:31+0800", "level": "INFO", "logger": "app.rag_service", "request_id": "3f2a1b9c...", "message": "llm_completed", "llm_latency_ms": 850.2, "answer_length": 36} {"timestamp": "2025-01-12T10:24:31+0800", "level": "INFO", "logger": "app.rag_service", "request_id": "3f2a1b9c...", "message": "request_completed", "total_latency_ms": 870.1}6.2 如何判断 Instrumentation 是否成功
有几个评估维度:
第一,能否回答“发生了什么”。给定一个用户反馈(“我昨天问的问题回答质量很差”),你能通过用户 ID 和大致时间范围找到那次请求,并看到当时的检索文档列表和 Prompt 模板版本吗?如果能,说明全景式记录做到了。
第二,能否回答“为什么发生”。找到那次请求后,你能不能看出是检索召回了不相关的文档,还是 Prompt 模板版本过旧,还是模型输出解析失败?这里要求链路事件足够完整。
第三,能否回答“影响范围多大”。你能否按prompt_template_version聚合,发现 v1.2 版本覆盖的流量中回答长度普遍偏短?或者按doc_ids聚合,发现某个文档被频繁召回但回答质量评分很低?
如果这三个问题都能回答,那就说明你的可观测性体系已经真正工作了。如果答不出来,问题通常在两个方面:一是记录的数据不够结构化,二是没有选对高基数属性。
6.3 一次失败排查的完整路径
假设有用户反馈:“有个问题回答得完全不对。”接下来你可以这样排查:
- 从日志系统按
user_query或用户 ID 模糊搜索找到request_id; - 按
request_id拉出全部事件; - 查看
retrieval_completed事件中的doc_ids和scores,确认召回结果是否相关; - 查看
prompt_assembled事件中的prompt_template_version和context_char_count,确认 Prompt 是否异常; - 查看
llm_completed事件中的answer_length,对比历史同类请求的分布,判断输出是否偏离。
每一步都在回答一个具体问题,这就是可观测性数据在排查中的真实用法。
7. AI 应用可观测性落地中的常见问题与排查思路
这里整理一些实际项目中经常遇到的问题,比理论更具体,也更贴近一线开发者的日常。
7.1 常见问题表格
| 问题现象 | 可能原因 | 排查方式 | 解决方案 |
|---|---|---|---|
| 日志里有事件但缺少 request_id | ContextVar 没有正确传递,或异步任务丢上下文 | 检查异步入口是否 reset 了 ContextVar | 用框架提供的上下文传递机制,或在消息头显式传递 trace_id |
| 检索阶段耗时很高但回答质量差 | 召回了大量低分文档,或 embedding 与 query 不匹配 | 查看scores分布,检查向量库配置 | 调整 top_k、优化分块策略、升级 embedding 模型 |
| 回答长度突然变短 | Prompt 模板或模型版本变更 | 按prompt_template_version聚合回答长度 | 回滚到正常版本,或在灰度环境验证后再全量 |
| 输出不是合法 JSON | 模型输出被截断或格式漂移 | 查看llm_completed的原文和解析错误日志 | 增加重试机制、使用结构化输出约束、增加格式校验 |
| 同一类问题的回答质量持续下降 | 向量库中文档陈旧或索引损坏 | 检查向量库变更记录和文档更新时间 | 定期重建索引、监控文档新鲜度 |
| 日志数据量太大,成本高 | 无差别记录全部 Prompt 和回答 | 查看存储成本占比,分析哪些字段使用率低 | 分层采样,核心链路全量记录,次要链路按比例采样 |
7.2 深入分析:为什么“日志有,但查不到”
这是最让人头疼的情况。团队确实加了日志,但排查时发现日志系统里按request_id一搜,只能搜到部分事件,链路是断的。
常见原因有两个。
第一个是异步任务没有传递 request_id。Python 里用了threading或asyncio,但没有把 ContextVar 的值传到子任务。修复方式是在创建子任务时显式传递,或者在消息队列的消息头里带上 trace_id。
第二个是日志采集端有采样策略。很多日志收集器默认会对高吞吐日志做采样(比如只采集 10%)。这在实际排障时是灾难,因为你需要的是一个完整请求的链路,而不是随机抽样后的碎片。建议对包含request_id的事件关闭采样策略,或至少保留 100% 请求的元数据事件。
7.3 深入分析:如何设计高基数属性
高基数属性(high cardinality attributes)是 Charity Majors 讲可观测性时反复提及的概念。它的意思是“每个请求都不同”的字段,比如user_id、request_id、doc_id、prompt_version。
这类属性不适合放进传统监控系统做聚合。传统监控的度量数据通常是预聚合的(比如按分钟求平均值),高基数属性会让预聚合瞬间爆炸。正因为如此,传统监控工具在应对 AI 应用时显得力不从心。
设计高基数属性的核心原则是:在写入日志的这一刻,把将来想查询的任何维度都提前放进去。
比如,当你怀疑“某个文档导致回答质量变差”时,就必须在retrieval_completed事件里记录doc_ids列表。如果当时没记,事后再想查就永远查不到了。日志记录无法回到过去补充。
7.4 成本控制的平衡策略
完整记录 AI 应用的链路数据确实有成本,尤其是 LLM API 调用量大时,日志量会非常可观。这里给出几条可行策略:
- 元数据全量,内容按需:
request_id、doc_ids、latency、版本号等元数据全部记录;完整 Prompt 和输出内容只在需要调试时开启。 - 分层采样:普通请求按 10% 采样,但涉及用户明确投诉、质量评分低于阈值的请求全量记录。
- 按阶段区分:检索阶段事件体积小,全量记录;LLM 输出阶段体积大,只记录截断摘要。
- 存储分级:热数据存储在查询快的引擎里存 7 天,冷数据转入低成本存储做长期归档。
成本控制的目的不是省那点存储费,而是让你在需要数据时能查到数据,同时不因为成本而被迫关闭可观测性。
8. 最佳实践与工程建议:把“吃蔬菜”变成流程
最后这部分,我想从更宏观的工程层面总结建议。Charity Majors 那句 “eating your broccoli” 的真正含义是:AI 工程没有捷径,该做的脏活累活一件都不能少。
8.1 建立“从生成到上线”的确定性回归机制
不要等 AI 应用上线后再考虑测试。从第一行代码开始,就应该意识到这个系统的行为分布随时可能变化。
推荐流程是:
- 每次修改代码,先跑纯逻辑的单元测试和黄金数据集检索测试;
- 每次修改 Prompt 模板,跑一遍行为测试(LLM-as-a-judge),并对比上一版本的评分分布;
- 每次升级模型版本或 embedding 模型,先跑完整的回归套件,灰度观察一段时间再全量。
这套流程不复杂,但需要有人持续执行。真正的难点不是工具,而是团队愿不愿意在看起来“没意思”的测试上花时间。
8.2 用“质量评分”统一语言
团队协作时,最大的问题是没有统一的质量标准。后端说“接口正常”,算法说“模型效果还行”,测试说“好像没啥大问题”——这种模糊沟通在 AI 工程里就是灾难。
建议引入一个简单的质量评分机制。每次请求完成后,用一个轻量规则(或 LLM judge)对回答质量打分,随其他事件一起记录。评分维度可以包括相关性、完整性、格式正确性。这样团队就有了一组可量化的数据:回答质量的平均分、低分请求的占比、质量评分在哪个 Prompt 版本上显著下降。
没有评分,你只能靠用户投诉被动发现问题;有了评分,你可以在用户感知之前主动干预。
8.3 从“链路追踪”走向“行为分析”
链路追踪告诉你“一次请求走了哪些步骤”,但对于 AI 应用来说,这还不够。你还需要回答“一组相似请求的行为分布是什么样的”。
比如,你可以按用户意图(query 聚类结果)分组,观察每个分组的回答平均分随时间的走势;也可以按检索文档分组,找出哪些文档经常被召回但回答质量反而不高。这种“行为分析”是 AI 可观测性的高阶形态,也是真正把数据变成决策的地方。
从实践路径来看,先做好链路追踪,再逐步积累行为分析能力,是更稳妥的顺序。
8.4 生产环境的安全与合规提醒
可观测性数据本身也可能引入风险。记录用户 query 和模型输出时,要充分考虑:
- 用户 query 可能包含个人隐私信息;
- 模型输出可能包含非预期的有害内容;
- 检索到的内部文档如果记录进日志,可能造成敏感信息泄露。
建议在日志链路中做脱敏处理,或在合规要求下限制内容日志的保留时长。安全边界与可观测性能力需要同时设计,不能等到出了问题再补。
8.5 团队协作与失败预案
AI 应用失败的可能性远高于传统系统。模型供应商宕机、API 限流、输出质量漂移,这些都可能发生。团队需要提前约定失败预案:
- 模型调用失败时,是否有降级方案(比如切换到小模型或本地模型)?
- 质量评分连续下跌时,谁负责决策是否回滚?
- 向量库索引损坏时,恢复流程是什么?
这些预案在平时看起来“用不上”,但一旦发生故障,它们就是团队最后的防线。建议把这类预案纳入例行的故障演练。
9. 总结:确定性留给系统,不确定性留给探索
回顾整篇文章,我想再次强调 Charity Majors 分享中最核心的一个判断:AI 系统确实引入了前所未有的复杂性和非确定性,但这不代表你可以跳过那些成熟的工程实践。恰恰相反,你需要把更多精力投入到基础工程中,才能让 AI 应用在生产环境里稳定运行。
从实践角度看,有三个动作最值得立刻去做:
第一,审视你的应用是否具备完整的 Instrumentation。没有结构化的、带完整上下文的日志,AI 应用出问题时就是盲人摸象。
第二,把能确定性的部分全部确定下来。通过单元测试、黄金数据集、schema 校验和回归测试,把可预测的环节锁定住,让不确定性只发生在模型生成层。
第三,用质量评分建立统一的反馈语言。让系统能够主动告诉你“质量在变差”,而不是被动等待用户投诉。
“吃蔬菜”确实不如“吃甜点”愉快。但真正能把 AI 应用做到生产级稳定运行的团队,一定是那些愿意在测试、可观测性、数据质量和失败预案上花功夫的团队。模型能力的差距可以用选型缩短,工程能力的差距却只能靠一步一步补齐。