1. 从三小时到一杯咖啡:故障复盘到底卡在哪
线上出故障,最折磨人的从来不是修,而是修完之后那场复盘。我经历过太多次这样的场景:凌晨两点被告警叫醒,一顿操作把服务拉回来,第二天下午被拉进会议室,对着三个小时的时间窗口,从五六个系统里翻日志,试图还原"到底哪一步先崩的"。等真正把时间线拼出来,咖啡都凉了两轮。
这个项目的出发点就一句话:把故障复盘的时间从三小时压到一杯咖啡的时间。做法是搭一个可追踪的日志调查 Agent,用蓝耘元生代作为模型与算力底座,用 LangGraph 编排调查流程,用 OpenTelemetry 统一日志与追踪的采集口径,最后让 Agent 自己按时间线把证据串起来,输出一份能直接贴进复盘文档的调查报告。
先说清楚它解决的是什么问题。传统排查日志的方式是"人肉 grep + 脑内拼图":你在终端里敲grep -i error,翻出一堆报错,但报错不等于根因,因为真正的第一现场往往是一条不起眼的超时或者连接池耗尽。人脑要同时记住时间戳、服务名、trace id、上下游调用关系,一旦跨三个以上服务,认知负荷直接爆表。这个 Agent 要做的,就是把这套"记忆 + 关联 + 推理"的活儿接过去。
它适合谁参考?三类人最对口。第一类是 SRE 和运维工程师,天天跟告警和复盘打交道;第二类是后端开发,需要定位自己服务在整条链路里的位置;第三类是对 Agent 编排感兴趣、想找一个真实落地场景练手的同学。你不需要是 LangGraph 专家,但得能看懂 Python,知道什么是 trace id,剩下的我会一步步拆。
关键词里出现的 LangGraph、OpenTelemetry、OpenAI 兼容接口,是这个项目的三根支柱。LangGraph 负责把"调查"这件事拆成有状态、可回溯的节点;OpenTelemetry 负责让日志和追踪说同一种语言;OpenAI 兼容接口则让模型调用可以灵活切换后端。蓝耘元生代在这里扮演的是"模型 + 运行环境"的角色,把模型接入和算力调度这两件麻烦事收拢到一处。
我先把整体思路摆出来,后面再逐层展开。整个 Agent 的工作流大致是:接收一个时间窗口和一组服务名,先去拉取该窗口内的日志与 trace 数据,然后按 trace id 做关联聚类,接着让模型对每个聚类做"异常判定 + 因果推断",最后汇总成一条带证据引用(evidence citation)的时间线。每一步都是一个 LangGraph 节点,状态在节点之间流转,出问题可以回放,这正是不用一条大 prompt 硬怼的原因。
提示:这个项目的核心价值不在"用了多新的模型",而在"把调查过程结构化、可追踪"。模型只是推理引擎,真正省时间的是流程编排和证据关联。
2. 为什么是 LangGraph 而不是一条大 Prompt
2.1 单 prompt 方案在日志调查上的三个硬伤
很多人第一反应是:日志调查嘛,把日志全塞进 prompt,让模型总结不就行了?我一开始也这么想,实测下来三个硬伤直接劝退。
第一个硬伤是上下文窗口和成本。一次故障窗口的日志量动辄几十万行,就算只筛 error 级别,也可能上万行。全塞进去,token 成本先不说,模型注意力会被稀释,关键的那几行反而被淹没。这就像让一个人在三小时监控录像里找一帧画面,你给他看完整录像,他大概率看花眼。
第二个硬伤是无法回溯中间步骤。单 prompt 是一次性黑盒,模型给出结论,你不知道它是基于哪几条日志推出来的。复盘最怕的就是"模型说根因是数据库,但拿不出证据"。而 LangGraph 的每个节点都有明确的输入输出,状态可持久化,等于给调查过程装了行车记录仪。
第三个硬伤是无法插入确定性逻辑。日志调查里有大量确定性操作,比如按 trace id 分组、按时间戳排序、统计某类错误出现频次。这些用代码做又快又准,没必要交给模型。LangGraph 允许你在节点里写普通 Python,只在真正需要推理的地方调模型,这是它比纯 prompt 方案高明的地方。
2.2 LangGraph 和 LangChain 的区别,落到这个项目上
热词里反复出现"langgraph 和 langchain 的区别",我用这个项目给你讲透。LangChain 更像一个"组件超市",提供 prompt 模板、模型封装、检索器这些零件,你按需取用。LangGraph 则是"流程引擎",它关心的是多个步骤之间怎么连、状态怎么传、出错怎么回退。
落到日志调查上:LangChain 能帮你把"调用模型"这件事封装得很干净,但它不天然管"先拉日志、再聚类、再推理、再汇总"这个顺序。LangGraph 用图(graph)的方式定义节点和边,天然适合这种有分支、有循环、有状态的流程。比如"如果某个聚类证据不足,就回到拉取节点补充数据"这种循环,用 LangGraph 的条件边几行就写完了,用 LangChain 得自己写一堆 if-else。
一句话总结:LangChain 管"用什么",LangGraph 管"怎么走"。这个项目两者都用,LangChain 的模型封装拿来调模型,LangGraph 负责编排整个调查流程。
2.3 状态设计:调查 Agent 的"记忆"长什么样
LangGraph 的核心是状态(state)。这个项目的状态我设计成一个 TypedDict,包含几个关键字段:time_window(调查时间窗口)、services(涉及的服务列表)、raw_logs(原始日志)、traces(追踪数据)、clusters(按 trace id 聚类后的结果)、findings(每个聚类的推理结论)、timeline(最终时间线)。
状态在节点之间流转,每个节点读取需要的字段,写回自己产出的字段。这样做的好处是,任何一步出问题,你都能把当时的状态 dump 出来看。我实测时遇到过一次模型把时间戳解析错,直接把clusters状态打印出来,五分钟就定位了,要是单 prompt 方案,只能重跑一遍碰运气。
注意:状态字段不要设计得太细碎,否则节点之间耦合会很重。我的经验是,按"数据形态"划分字段,而不是按"处理步骤"划分。比如
clusters是数据形态,cluster_step是步骤,后者不该进状态。
3. 用 OpenTelemetry 把日志和追踪拧成一股绳
3.1 为什么日志调查必须先解决"数据口径"问题
这个项目最容易翻车的地方,不是模型,是数据。我见过太多团队,日志在一个系统,trace 在另一个系统,两边的服务名对不上,时间戳一个用本地时间一个用 UTC,trace id 一个带前缀一个不带。Agent 再聪明,喂进去的数据对不上,出来的结论就是错的。
OpenTelemetry(简称 OTel)在这里的价值,是提供一套统一的语义约定(semantic conventions)。它规定了 trace id、span id、service.name、timestamp 这些字段应该叫什么、什么格式。只要你的日志和追踪都按 OTel 的约定来,Agent 做关联时就不用写一堆适配代码。
具体到这个项目,我要求日志里必须带trace_id和span_id字段,追踪数据里必须有service.name和start_time。这样 Agent 拿到一条日志,能通过 trace_id 找到它属于哪个调用链;拿到一个 span,能通过 service.name 知道是哪个服务。关联的钥匙就是 trace_id。
3.2 日志采集侧的最小改造
如果你现在的日志是纯文本,没有结构化字段,别急着上全套 OTel SDK,先做最小改造:在日志输出里加上 trace_id。以 Python 为例,用 OTel 的 logging instrumentation,几行代码就能让日志自动带上当前 span 的 trace_id。
import logging from opentelemetry import trace from opentelemetry.sdk.trace import TracerProvider from opentelemetry.sdk.trace.export import BatchSpanProcessor, ConsoleSpanExporter # 初始化 tracer provider = TracerProvider() processor = BatchSpanProcessor(ConsoleSpanExporter()) provider.add_span_processor(processor) trace.set_tracer_provider(provider) tracer = trace.get_tracer(__name__) # 在业务代码里开 span,日志会自动关联 with tracer.start_as_current_span("handle_request") as span: span.set_attribute("service.name", "order-service") logging.info("订单创建开始") # 这条日志会带上 trace_id这段代码的关键是start_as_current_span,它把当前 span 放进上下文,日志处理器会自动读取上下文里的 trace_id 注入到日志记录里。改造完之后,你的日志从"孤立的文本行"变成"挂在调用链上的节点",这是 Agent 能做关联的前提。
3.3 追踪数据的采样策略:别让成本失控
全量采集 trace 的成本很高,尤其是高 QPS 服务。我的做法是分层采样:正常请求按 1% 采样,错误请求 100% 采集。OTel 支持基于属性的采样器,你可以写一个自定义 Sampler,判断 span 的状态码,如果是 error 就返回 RECORD_AND_SAMPLE,否则按概率采样。
from opentelemetry.sdk.trace.sampling import Sampler, SamplingResult, Decision class ErrorBiasedSampler(Sampler): def should_sample(self, parent_context, trace_id, name, kind, attributes, links): # 错误请求全采,正常请求 1% 采样 if attributes and attributes.get("error") is True: return SamplingResult(Decision.RECORD_AND_SAMPLE) if trace_id % 100 == 0: return SamplingResult(Decision.RECORD_AND_SAMPLE) return SamplingResult(Decision.DROP) def get_description(self): return "ErrorBiasedSampler"这个采样器保证故障期间的证据不会丢,同时把日常成本压下来。实测下来,错误全采 + 正常 1% 采样,存储成本大约是全量的 3% 到 5%,但故障复盘需要的证据一条不少。
提示:采样决策要在 trace 的根 span 上做,子 span 跟随根 span 的决策,否则会出现"根 span 被采样但子 span 被丢弃"的断裂链路,Agent 关联时会报错。
4. 在蓝耘元生代上把模型接进来
4.1 为什么选蓝耘元生代做底座
这个项目要调模型做推理,绕不开两个问题:模型从哪来,算力从哪来。蓝耘元生代把这两件事收拢到一起,提供 OpenAI 兼容的接口,意味着你现有的 OpenAI SDK 代码几乎不用改,把 base_url 和 api_key 换掉就能跑。
我选它的实际理由有三条。第一是接口兼容,OpenAI 兼容接口让 LangChain 的ChatOpenAI封装直接可用,省去自己写适配层。第二是模型可选,不同任务对模型能力要求不同,异常判定这种需要强推理的用大模型,日志摘要这种用轻量模型就够,按需切换能省不少成本。第三是环境省心,不用自己折腾算力调度,把精力放在 Agent 逻辑上。
4.2 接入代码:三行改动跑通
假设你原来用的是标准 OpenAI 接口,接入蓝耘元生代只需要改 base_url 和 api_key。
from langchain_openai import ChatOpenAI llm = ChatOpenAI( model="your-model-name", base_url="https://your-lanyun-endpoint/v1", api_key="your-lanyun-api-key", temperature=0, ) # 测试调用 resp = llm.invoke("用一句话解释什么是 trace id") print(resp.content)temperature=0是刻意的,日志调查要的是稳定复现的推理,不是创意发挥。同样的日志喂进去,两次跑出来结论差太多,复盘就没法信。
4.3 模型调用的成本与延迟控制
日志调查里模型调用次数可能很多,每个聚类都要调一次。我的控制策略是先过滤再推理:在调模型之前,用确定性代码把明显正常的聚类过滤掉,只把"有异常信号"的聚类交给模型。异常信号包括:包含 error 级别日志、span 状态为 error、耗时超过 P99 阈值。
这样做的效果很明显。一次典型故障,原始聚类可能有几百个,过滤后真正需要模型推理的往往只有十几个。模型调用次数降一个数量级,成本和延迟都下来了。而且过滤逻辑是确定性的,可解释,不会引入模型的不确定性。
def is_suspicious(cluster): """判断聚类是否值得交给模型推理""" if any(log["level"] == "ERROR" for log in cluster["logs"]): return True if cluster.get("span_status") == "error": return True if cluster.get("duration_ms", 0) > cluster.get("p99_threshold", float("inf")): return True return False suspicious = [c for c in clusters if is_suspicious(c)]5. 调查 Agent 的节点拆解与编排
5.1 五个核心节点的职责边界
整个调查流程我拆成五个节点,每个节点只干一件事,职责边界清晰。
第一个节点是数据拉取(fetch),根据时间窗口和服务列表,从日志系统和追踪系统拉数据。第二个节点是关联聚类(cluster),按 trace id 把日志和 span 归到一组。第三个节点是异常筛选(filter),用确定性规则过滤出可疑聚类。第四个节点是推理判定(reason),把可疑聚类交给模型,让它判断异常类型和可能的因果。第五个节点是时间线汇总(summarize),把所有 findings 按时间排序,生成带证据引用的报告。
这五个节点串成一条主链,中间加一个条件边:如果推理节点发现某个聚类证据不足(比如日志被采样丢弃了),就回到数据拉取节点,带上更精确的查询条件重新拉。这个循环是 LangGraph 相比线性流程的优势所在。
5.2 用 LangGraph 定义图结构
from langgraph.graph import StateGraph, END from typing import TypedDict, List class InvestigationState(TypedDict): time_window: dict services: List[str] raw_logs: List[dict] traces: List[dict] clusters: List[dict] findings: List[dict] timeline: List[dict] retry_count: int def fetch_node(state): ... def cluster_node(state): ... def filter_node(state): ... def reason_node(state): ... def summarize_node(state): ... def should_retry(state): # 证据不足且重试次数未超限,回到拉取 if state.get("need_more_data") and state["retry_count"] < 2: return "fetch" return "summarize" graph = StateGraph(InvestigationState) graph.add_node("fetch", fetch_node) graph.add_node("cluster", cluster_node) graph.add_node("filter", filter_node) graph.add_node("reason", reason_node) graph.add_node("summarize", summarize_node) graph.set_entry_point("fetch") graph.add_edge("fetch", "cluster") graph.add_edge("cluster", "filter") graph.add_edge("filter", "reason") graph.add_conditional_edges("reason", should_retry, {"fetch": "fetch", "summarize": "summarize"}) graph.add_edge("summarize", END) app = graph.compile()retry_count这个字段很关键,它防止无限循环。我设的上限是 2,实测够用,再多说明数据本身有问题,该去查采集而不是让 Agent 硬撑。
5.3 推理节点的 prompt 设计:让模型给证据不给结论
推理节点的 prompt 是整个项目里我改得最多的地方。早期版本我让模型"判断根因",结果它经常给出很自信但没证据的结论。后来我把 prompt 改成"只描述你从这些日志里观察到什么,每条观察必须引用具体的日志行",效果立刻不一样。
REASON_PROMPT = """你是一个日志调查助手。下面是一个调用链聚类的日志和追踪数据。 请只做两件事: 1. 描述你观察到的异常现象,每条观察必须引用具体的日志行(用行号或时间戳)。 2. 给出这个聚类最可能的异常类型(如超时、连接拒绝、空指针、资源耗尽)。 不要猜测根因,不要给出修复建议。只描述观察和异常类型。 数据: {cluster_data} 输出格式(JSON): {{"observations": [{{"text": "...", "evidence": "..."}}], "anomaly_type": "..."}} """这个 prompt 的核心是限制模型的输出范围。根因推断交给汇总节点,因为根因往往需要跨聚类看,单个聚类看不出来。让模型在单聚类层面只做观察,能大幅降低幻觉。
注意:prompt 里明确要求 JSON 输出,配合 LangChain 的 structured output 解析,能避免模型输出一堆散文导致解析失败。我踩过这个坑,模型偶尔会加一句"综上所述",解析直接崩。
6. 实测中的坑与排查链路
6.1 trace id 对不上:一次完整的排查过程
项目第一次跑通后,我发现 Agent 输出的时间线里,很多日志没有关联到 trace。排查过程我完整记录一下,因为这类问题太常见了。
第一步,我先确认数据本身有没有 trace_id。把raw_logs状态 dump 出来,发现部分日志的 trace_id 字段是空的。第二步,我去查日志采集侧,发现这些日志来自一个老服务,它的日志框架没接 OTel。第三步,我临时给这个服务加了一个日志过滤器,手动从 MDC(Mapped Diagnostic Context)里读 trace_id 注入。第四步,重跑,关联率从 60% 提到 95%。
剩下的 5% 是采样丢弃导致的,属于预期内。这个排查链路说明一个道理:Agent 的问题,八成在数据,不在模型。遇到关联失败,先查数据口径,别急着调 prompt。
6.2 模型把时间戳解析错:状态回放救了我
有一次 Agent 输出的时间线顺序完全乱了。我一开始怀疑是排序代码写错,查了半天没问题。后来把findings状态 dump 出来,发现模型在推理节点里把时间戳当成了字符串直接比较,导致 "10:00" 排在 "9:00" 后面。
修复方式是在推理节点之后加一个规范化步骤,把所有时间戳统一转成 ISO 8601 格式再交给汇总节点。这个坑的教训是:不要相信模型会正确处理格式,格式规范化要用代码做。
6.3 循环重试把成本跑飞:retry_count 的必要性
早期版本我没加retry_count,结果遇到一个数据缺失的聚类,Agent 在 fetch 和 reason 之间来回跳,模型调用次数飙升。加了retry_count上限后,最多重试两次就强制走汇总,并在报告里标注"证据不足"。这比无限重试健康得多,复盘时看到"证据不足"的标注,反而知道该去补哪块数据。
| 问题现象 | 根因 | 修复方式 |
|---|---|---|
| 日志关联不到 trace | 老服务未接 OTel | 手动注入 trace_id |
| 时间线顺序错乱 | 模型按字符串比较时间 | 代码统一转 ISO 8601 |
| 模型调用成本飙升 | 无重试上限 | 加 retry_count 上限 |
| 推理结论无证据 | prompt 未限制输出 | 要求引用具体日志行 |
7. 把复盘报告做成能直接用的东西
7.1 报告结构:时间线 + 证据 + 待确认项
Agent 最终输出的报告,我设计成三块。第一块是时间线,按时间顺序列出关键事件,每条事件带证据引用(哪条日志、哪个 span)。第二块是异常聚类摘要,列出每个可疑聚类的异常类型和观察。第三块是待确认项,把证据不足或模型不确定的地方单独列出来,提示人去核实。
这个结构的好处是,复盘会上你可以直接投屏,时间线部分大家看事实,待确认项部分大家讨论。Agent 不替人做决策,只把事实和疑点摆清楚。
7.2 证据引用怎么做才可信
证据引用不能只写"见日志",要精确到可复现。我的做法是每条证据带上三个要素:服务名、时间戳、trace_id。有了这三个,任何人拿着去日志系统里一查就能找到原始记录。这比模型说"根据日志分析"可信一百倍。
def format_evidence(log): return { "service": log["service.name"], "timestamp": log["timestamp"], "trace_id": log["trace_id"], "snippet": log["message"][:200], }snippet截断到 200 字符,避免报告里塞进超长堆栈。需要看全文的,拿 trace_id 去查。
7.3 从三小时到一杯咖啡,实际省在哪
最后算笔账。传统复盘,人工翻日志、拼时间线、写报告,三小时是保守估计。用这个 Agent,数据拉取和聚类是秒级,模型推理十几个聚类大概一两分钟,汇总生成报告几十秒。人要做的是审核报告、补充待确认项,大概二十分钟。从三小时到二十分钟,一杯咖啡还没喝完。
省下来的时间主要在两块:一是关联聚类,人脑做这个最累,代码做最快;二是证据整理,模型把散落的日志行按时间线串好,人不用再一条条复制粘贴。模型真正贡献的是"把非结构化日志转成结构化观察",而不是"替人做根因判断"。
我在实际使用中的体会是,这个 Agent 最大的价值不是全自动,而是把人的注意力从"找数据"转移到"判断数据"。找数据是体力活,判断才是脑力活。把体力活交给 Agent,复盘的质量和速度都会上一个台阶。
后续可以扩展的方向,我列几个自己打算做的:一是接入告警系统,故障发生时自动触发调查;二是把历史复盘报告做成知识库,让 Agent 参考类似故障的处理经验;三是给报告加一个"置信度"字段,让模型对自己的观察打个分,方便人快速筛选。这几个方向都不难,核心流程已经跑通,剩下的都是增量。