我把 800 行报错日志压成一张排障卡:用蓝耘元生代做 FastAPI 日志根因分析

文章目录

这篇文章记录一个可以直接运行的小项目:接收 FastAPI、Nginx 等服务的错误日志,通过蓝耘元生代 MaaS 调用不同模型,生成结构化的故障摘要、根因判断和排查步骤。重点不是做一个"会聊天"的页面,而是把值班时最费时间的日志整理工作先交给模型。

一、为什么要做这个工具

我把目标场景定在一个很常见的线上故障:订单接口间歇性返回 502,监控里同时出现 Nginx upstream timeout、Python 数据库连接超时和 Redis 重连日志。类似事故往往会在十几分钟内积累几百行重复信息。

真正有用的信息其实不多:故障从什么时候开始、哪个服务最先异常、错误有没有固定请求路径、应该先查数据库还是网关。但人刚被告警叫起来时,很容易顺着最后一条错误往下查,结果在次要问题上绕一圈。本文使用后面给出的脱敏模拟日志复现这条链路,不把示例数据冒充真实生产事故。

我想做的工具很克制,只完成四件事:

  1. 从日志中提取时间范围、服务名和关键错误;
  2. 把重复堆栈折叠成故障摘要;
  3. 给出有证据支撑的可能根因,不允许把猜测写成结论;
  4. 生成按顺序执行的排查清单。

这个任务适合接 MaaS。日志分析请求通常不连续,凌晨可能突然来一批,平时又几乎没有调用。为了这种波峰波谷自己维护一套 GPU 推理服务并不划算。

二、项目结构和处理流程

项目使用 FastAPI 提供接口,Python 负责日志预处理,蓝耘元生代负责语义分析。流程如下:

text 复制代码
原始日志
   ↓
大小与格式检查
   ↓
正则提取时间、级别、服务名
   ↓
相同错误去重并保留出现次数
   ↓
根据日志长度选择模型
   ↓
调用蓝耘 OpenAI 兼容接口
   ↓
校验 JSON 输出
   ↓
返回故障摘要、证据、根因候选和排查步骤

我没有把完整原始日志不加处理地扔给模型。预处理可以减少重复 Token,也能保留"某错误出现 37 次"这种频次信息。模型擅长理解上下文,但基础清洗还是普通代码更稳。

本项目目录很小:

text 复制代码
log-doctor/
├── app.py
├── analyzer.py
├── sample.log
├── requirements.txt
└── .env.example

三、蓝耘元生代在项目中承担什么

蓝耘在这里是模型服务层。FastAPI 处理 HTTP 请求,预处理代码压缩日志,蓝耘 MaaS 接收整理后的文本并返回分析结果。

我选择蓝耘主要有三个实际原因:

  • 接口兼容 OpenAI SDK,现有 Python 项目只需修改 base_url、API Key 和模型 ID;
  • 同一入口可以配置不同模型,短日志用响应快的模型,长堆栈换成长文本能力更合适的模型;
  • 模型和密钥集中在平台侧管理,不需要为偶发的日志分析任务维护推理服务。

几种方案各有适用范围:

方案 初次接入 日常运维 数据边界 适合场景
本地部署开源模型 较高 需要维护显存、服务和模型文件 数据可留在内网 日志不能出内网、调用长期稳定
直接连接单一模型厂商 较低 较少 取决于服务协议 团队已固定使用某一模型
蓝耘元生代 MaaS 较低 较少 调用前仍需自行脱敏 希望统一接口并灵活选择模型

这不是说 MaaS 一定优于本地部署。涉及密码、Token、手机号、内网地址的日志,必须先脱敏;如果公司规定日志不能离开内网,就应选择本地模型。

四、准备蓝耘 MaaS 配置

1. 创建项目专用 API Key

进入蓝耘元生代 MaaS 控制台,在 API Key 管理页创建一枚只给日志助手使用的密钥。不要复用个人测试 Key,也不要把密钥直接写进代码仓库。

图 1:蓝耘 MaaS API Key 管理操作示意。实际发布时建议替换成自己的控制台截图,并遮挡完整密钥。

OpenAI 兼容接口地址为:

text 复制代码
https://maas-api.lanyun.net/v1

2. 从模型列表复制完整 ID

模型 ID 不要凭展示名称手写。蓝耘模型通常带有完整命名空间,例如:

text 复制代码
/maas/deepseek-ai/DeepSeek-V3.2

模型上下架和命名可能调整,运行时应以本人控制台当前显示为准。

图 2:蓝耘 MaaS 模型列表操作示意。需要记录完整模型 ID,而不是只抄页面上的短名称。

3. 配置环境变量

.env.example 内容如下:

dotenv 复制代码
LANYUN_API_KEY=替换为自己的API_KEY
LANYUN_BASE_URL=https://maas-api.lanyun.net/v1
LANYUN_FAST_MODEL=/maas/deepseek-ai/DeepSeek-V3.2
LANYUN_LONG_MODEL=/maas/deepseek-ai/DeepSeek-V3.2
LOG_MAX_CHARS=60000

为了让示例可以直接跑,两个任务先使用同一个模型。实际项目中可以把 LANYUN_FAST_MODEL 换成更轻量的模型,把 LANYUN_LONG_MODEL 配成长上下文模型,业务代码不用改。

五、完整代码

1. 安装依赖

requirements.txt

text 复制代码
fastapi==0.115.12
uvicorn[standard]==0.34.2
openai==1.78.1
python-dotenv==1.1.0
pydantic==2.11.4

安装:

bash 复制代码
python -m venv .venv

Windows PowerShell:

powershell 复制代码
.\.venv\Scripts\Activate.ps1
pip install -r requirements.txt
Copy-Item .env.example .env

Linux 或 macOS:

bash 复制代码
source .venv/bin/activate
pip install -r requirements.txt
cp .env.example .env

2. 日志预处理和模型调用

analyzer.py

python 复制代码
import hashlib
import json
import os
import re
from collections import Counter
from typing import Any

from dotenv import load_dotenv
from openai import OpenAI

load_dotenv()

BASE_URL = os.getenv("LANYUN_BASE_URL", "https://maas-api.lanyun.net/v1")
FAST_MODEL = os.environ["LANYUN_FAST_MODEL"]
LONG_MODEL = os.environ["LANYUN_LONG_MODEL"]
MAX_CHARS = int(os.getenv("LOG_MAX_CHARS", "60000"))

client = OpenAI(
    api_key=os.environ["LANYUN_API_KEY"],
    base_url=BASE_URL,
    timeout=90.0,
)

TIME_PATTERN = re.compile(r"\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2}(?:[.,]\d+)?")
LEVEL_PATTERN = re.compile(r"\b(DEBUG|INFO|WARN|WARNING|ERROR|CRITICAL)\b", re.I)
SECRET_PATTERNS = [
    (re.compile(r"(?i)(authorization:\s*bearer\s+)[^\s]+"), r"\1***"),
    (re.compile(r"(?i)(api[_-]?key[=:]\s*)[^\s,;]+"), r"\1***"),
    (re.compile(r"(?i)(password[=:]\s*)[^\s,;]+"), r"\1***"),
    (re.compile(r"\b1[3-9]\d{9}\b"), "[PHONE]"),
]

SYSTEM_PROMPT = """
你是生产环境日志分析助手。只根据提供的日志作答,不补造系统信息。
请输出一个 JSON 对象,字段必须为:
summary:不超过80字的故障摘要;
time_range:日志覆盖时间,无法判断时写 unknown;
services:涉及的服务名数组;
key_errors:关键错误数组,每项包含 message、count、evidence;
root_causes:根因候选数组,每项包含 cause、confidence、evidence;
actions:按优先级排列的排查步骤数组;
missing_context:仍需补充的信息数组。

confidence 只能是 high、medium、low。
证据不足时必须降低置信度,禁止把猜测写成确定事实。
不要输出 Markdown,不要在 JSON 前后添加解释。
""".strip()


def redact_secrets(text: str) -> str:
    for pattern, replacement in SECRET_PATTERNS:
        text = pattern.sub(replacement, text)
    return text


def normalize_line(line: str) -> str:
    line = TIME_PATTERN.sub("<TIME>", line)
    line = re.sub(r"request_id=[a-zA-Z0-9-]+", "request_id=<ID>", line)
    line = re.sub(r"\b\d+ms\b", "<N>ms", line)
    return line.strip()


def compress_log(raw_log: str) -> dict[str, Any]:
    safe_log = redact_secrets(raw_log[:MAX_CHARS])
    lines = [line.strip() for line in safe_log.splitlines() if line.strip()]
    normalized = [normalize_line(line) for line in lines]
    counts = Counter(normalized)

    important = []
    for original, normalized_line in zip(lines, normalized):
        level = LEVEL_PATTERN.search(original)
        if level and level.group(1).upper() in {"WARN", "WARNING", "ERROR", "CRITICAL"}:
            important.append(
                {
                    "line": original[:500],
                    "count": counts[normalized_line],
                }
            )

    unique_important = []
    seen = set()
    for item in important:
        key = normalize_line(item["line"])
        if key not in seen:
            seen.add(key)
            unique_important.append(item)

    times = TIME_PATTERN.findall(safe_log)
    return {
        "sha256": hashlib.sha256(safe_log.encode("utf-8")).hexdigest(),
        "original_lines": len(lines),
        "time_start": times[0] if times else "unknown",
        "time_end": times[-1] if times else "unknown",
        "important_lines": unique_important[:120],
        "truncated": len(raw_log) > MAX_CHARS,
    }


def choose_model(payload: dict[str, Any]) -> str:
    if payload["original_lines"] > 300 or len(payload["important_lines"]) > 60:
        return LONG_MODEL
    return FAST_MODEL


def analyze_log(raw_log: str) -> dict[str, Any]:
    compressed = compress_log(raw_log)
    model = choose_model(compressed)

    response = client.chat.completions.create(
        model=model,
        messages=[
            {"role": "system", "content": SYSTEM_PROMPT},
            {
                "role": "user",
                "content": json.dumps(compressed, ensure_ascii=False),
            },
        ],
        temperature=0.1,
        response_format={"type": "json_object"},
    )

    content = response.choices[0].message.content or "{}"
    result = json.loads(content)
    result["meta"] = {
        "model": model,
        "log_sha256": compressed["sha256"],
        "original_lines": compressed["original_lines"],
        "truncated": compressed["truncated"],
        "usage": response.usage.model_dump() if response.usage else None,
    }
    return result

这段代码有几个刻意保留的工程细节:

  • 请求模型前先替换 API Key、密码和手机号;
  • 时间、请求 ID 和耗时数字会被归一化,便于统计重复错误;
  • 只把警告和错误行作为主要证据,但保留原始行数与时间范围;
  • 最终结果记录实际模型、日志哈希和 Token 用量,方便复盘;
  • 超过长度限制的日志明确标记 truncated,不假装分析了全部内容。

3. FastAPI 接口

app.py

python 复制代码
from typing import Any

from fastapi import FastAPI, HTTPException
from pydantic import BaseModel, Field

from analyzer import analyze_log

app = FastAPI(title="Log Doctor", version="1.0.0")


class AnalyzeRequest(BaseModel):
    log: str = Field(min_length=20, max_length=200000)


class AnalyzeResponse(BaseModel):
    result: dict[str, Any]


@app.get("/health")
def health() -> dict[str, str]:
    return {"status": "ok"}


@app.post("/analyze", response_model=AnalyzeResponse)
def analyze(request: AnalyzeRequest) -> AnalyzeResponse:
    try:
        return AnalyzeResponse(result=analyze_log(request.log))
    except ValueError as exc:
        raise HTTPException(status_code=502, detail="模型返回了无效 JSON") from exc

启动服务:

powershell 复制代码
uvicorn app:app --host 0.0.0.0 --port 8000

浏览器打开 http://127.0.0.1:8000/docs,可以看到 FastAPI 自动生成的接口页面。也可以直接使用 PowerShell 请求。

六、用一段故障日志验证

sample.log

text 复制代码
2026-08-18 21:03:11 ERROR gateway request_id=a1 upstream timed out, path=/api/orders, cost=30002ms
2026-08-18 21:03:12 ERROR order-api request_id=a1 sqlalchemy.exc.TimeoutError: QueuePool limit of size 10 overflow 20 reached
2026-08-18 21:03:14 WARN order-api request_id=b2 redis connection retry 1
2026-08-18 21:03:16 ERROR gateway request_id=c3 upstream timed out, path=/api/orders, cost=30001ms
2026-08-18 21:03:17 ERROR order-api request_id=c3 sqlalchemy.exc.TimeoutError: QueuePool limit of size 10 overflow 20 reached
2026-08-18 21:03:20 INFO user-api request_id=d4 GET /api/profile 200 cost=42ms
2026-08-18 21:03:26 ERROR gateway request_id=e5 upstream timed out, path=/api/orders, cost=30000ms
2026-08-18 21:03:27 ERROR order-api request_id=e5 sqlalchemy.exc.TimeoutError: QueuePool limit of size 10 overflow 20 reached

PowerShell 调用:

powershell 复制代码
$body = @{
  log = Get-Content .\sample.log -Raw
} | ConvertTo-Json

Invoke-RestMethod `
  -Method Post `
  -Uri http://127.0.0.1:8000/analyze `
  -ContentType "application/json" `
  -Body $body | ConvertTo-Json -Depth 10

这组日志返回的核心结果应接近下面的结构。模型措辞可能略有变化,判断必须以日志证据为准:

json 复制代码
{
  "result": {
    "summary": "订单接口多次超时,order-api 同期出现数据库连接池耗尽,网关 502 很可能是下游请求阻塞造成的。",
    "time_range": "2026-08-18 21:03:11 - 2026-08-18 21:03:27",
    "services": ["gateway", "order-api", "user-api"],
    "key_errors": [
      {
        "message": "order-api 数据库连接池耗尽",
        "count": 3,
        "evidence": "sqlalchemy.exc.TimeoutError: QueuePool limit of size 10 overflow 20 reached"
      },
      {
        "message": "网关访问 /api/orders 超时",
        "count": 3,
        "evidence": "upstream timed out, path=/api/orders"
      }
    ],
    "root_causes": [
      {
        "cause": "order-api 无法及时取得数据库连接,导致请求堆积并触发网关超时",
        "confidence": "high",
        "evidence": "三次网关超时均与三次 QueuePool TimeoutError 同期出现"
      },
      {
        "cause": "Redis 短暂重连放大了接口延迟",
        "confidence": "low",
        "evidence": "只出现一次 redis retry,证据不足"
      }
    ],
    "actions": [
      "检查故障时段数据库活跃连接数、慢查询和连接占用时长",
      "检查 order-api 连接是否在异常路径中未释放",
      "核对 SQLAlchemy pool_size、max_overflow 与实例并发量",
      "确认数据库恢复后再评估是否需要调整网关超时时间"
    ],
    "missing_context": [
      "数据库监控数据",
      "order-api 实例数和请求并发量",
      "完整异常堆栈"
    ],
    "meta": {
      "model": "/maas/deepseek-ai/DeepSeek-V3.2",
      "original_lines": 8,
      "truncated": false
    }
  }
}

这个结果里我最看重的不是"数据库连接池耗尽"这句话,而是它把高置信度判断和低置信度猜测分开了。Redis 只重连一次,不能仅凭这一行就认定它是主因。

截图 3:实际运行时可截取 FastAPI /docs/analyze 的响应,画面保留 summaryroot_causesactionsmeta.model,日志中的业务信息需要脱敏。

七、一个实际会碰到的问题:模型返回 JSON,但程序仍然解析失败

只靠提示词约束输出时,很容易遇到一种失败:请求状态是 200,肉眼看返回内容也像 JSON,程序却报:

text 复制代码
json.decoder.JSONDecodeError: Expecting value: line 1 column 1 (char 0)

打印 repr(content) 后可以看到,模型可能在 JSON 前后加上 Markdown 代码围栏:

text 复制代码
```json
{"summary": "..."}
```

这类输出给人看没问题,给 json.loads 就会失败。

只在提示词里写"只输出 JSON"并不稳妥。项目代码使用两层约束:

python 复制代码
response = client.chat.completions.create(
    model=model,
    messages=messages,
    temperature=0.1,
    response_format={"type": "json_object"},
)

同时在 system prompt 中明确字段、枚举值和"不要输出 Markdown"。正式发布前,应准备至少 30 份脱敏日志跑结构化回归,并记录解析成功率;没有真实调用数据时,不应编造一个好看的通过率。

这里有个边界要说清:response_format 能提高格式稳定性,但不能保证字段内容一定正确。生产环境仍应使用 Pydantic 或 JSON Schema 校验字段,并把校验失败的结果送入人工复核,而不是直接写进事故记录。

八、另一个容易忽略的问题:日志脱敏不能只靠提示词

最初版本在提示词里写了"请忽略日志中的密码和 Token"。这并不等于安全,因为敏感内容已经随请求发送出去了。正确顺序应该是先在本地替换,再调用模型。

本文代码处理了四类常见内容:Bearer Token、API Key、password 字段和手机号。真实系统还要根据日志规范加入这些规则:

  • 数据库连接串;
  • Cookie、Session ID 和 JWT;
  • 邮箱、身份证号、订单号;
  • 内网 IP、主机名和租户 ID;
  • 用户输入中可能包含的隐私文本。

如果无法确定日志是否允许发送到外部服务,就不要调用 MaaS。技术便利不能代替公司的数据合规要求。

九、如何部署

这个工具没有状态,适合直接用 Docker 运行。Dockerfile 可以这样写:

dockerfile 复制代码
FROM python:3.12-slim

WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY app.py analyzer.py ./
EXPOSE 8000

CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8000"]

构建和启动:

powershell 复制代码
docker build -t log-doctor:1.0 .
docker run --rm -p 8000:8000 --env-file .env log-doctor:1.0

部署到服务器后,还应在接口前加公司现有的鉴权,不要把 /analyze 裸露到公网。日志接口也不建议记录完整请求体,否则"分析日志的服务"又会复制一份敏感日志。

十、模型怎么分工更合理

同一项目使用多个模型时,我没有让它们互相讨论,而是按输入规模路由:

python 复制代码
def choose_model(payload: dict) -> str:
    if payload["original_lines"] > 300 or len(payload["important_lines"]) > 60:
        return LONG_MODEL
    return FAST_MODEL

短日志通常只有几十行,任务是分类和摘要,使用轻量模型足够。长日志可能包含多段堆栈和跨服务时间线,再切到长上下文模型。

上线前应该用自己的日志回归集比较:

检查项 判断方法
关键错误召回 人工标出的错误是否都进入 key_errors
根因证据 每个根因是否能在日志中找到对应行
过度推断 是否出现日志没有提供的组件、版本和配置
JSON 稳定性 能否通过固定 Schema 校验
成本 记录输入、输出 Token 和单次调用模型
延迟 记录 P50、P95,而不是只看最快的一次

模型切换后要重新跑同一批样本。接口兼容只说明代码可以调用,不代表不同模型的字段稳定性和判断习惯完全一样。

十一、这次实战得到的结论

这个日志助手没有替代值班工程师,也不应该自动执行扩容、重启或数据库操作。它真正省下的是事故刚发生时的整理时间:先把大量重复日志压成一张有证据的排障卡,再由人决定下一步。

蓝耘元生代在项目中的作用也很明确:提供统一的模型调用入口,让短日志和长日志可以在同一套 SDK 下选择不同模型。项目代码主要负责脱敏、压缩、校验和审计,模型负责理解上下文。

如果要把这个示例用于真实业务,我建议先做三件事:准备 20 到 50 份脱敏历史日志作为回归集;补全符合公司数据规范的脱敏规则;把输出限制在"建议",不让模型直接执行生产操作。

做完这三步,它才是一个排障工具,而不是把日志复制给聊天机器人。

相关推荐
IanSkunk1 小时前
眼视光设备全周期台账:从验收入库到使用效果数据化的管理闭环
大数据·网络·人工智能
好评1241 小时前
【Linux】网络基础概念
linux·网络
捧 花1 小时前
FastAPI 基础语法:从一个完整接口理解 Web API 的设计
前端·python·fastapi·middleware
杨云龙UP1 小时前
Toad for Oracle 调整表空间 Datafile 大小:Usage、Used Pct of Max 与 RESIZE 实战
linux·运维·服务器·数据库·oracle·dba·数据库运维
Zguigo2 小时前
lesson43-44深入理解 TCP 可靠传输与流量控制:从机制到实战
网络·网络协议·tcp/ip
ai产品老杨2 小时前
视频分析网络穿透项目实战记录:内网摄像头远程调试与跨网接入手册
网络·音视频
河北之花2 小时前
计算机三级:路由器选型
网络·计算机网络·智能路由器
卷无止境2 小时前
除了开发api,FastAPI其实也可以配合jinja2模板写页面
后端·python·fastapi
不可求~3 小时前
调用 GPT、Claude、Gemini API 报错怎么办:一套通用排查清单
网络·chrome·gpt