Skip to content

报告评估与运行

ops-log-agent 现在已经有三份关键材料:outputs/evidence.json 记录日志证据,outputs/runbook-index.json 记录 Runbook 索引,skills/log-triage/SKILL.md 记录分析规则。scripts/run_agent.py 还差运行入口,把模型调用、工具循环、报告保存和 trace 保存接起来。

模型输出一段 Markdown 看起来能用,但程序没法稳定检查字段;只保存报告也不够,出了错不知道模型到底有没有查证据、有没有命中 Runbook。这里把报告拆成两份文件:report.md 给人看,run-trace.json 给排查用。

一、运行链路

当前项目的运行链路是这样:

read_logsbuild_index 都是确定性脚本,输入一样时输出也应该一样。run_agent 这一步多了模型调用,结果会受提示词、工具调用、模型版本和网络状态影响,所以要保存运行记录。报告如果写偏了,trace 里要能看到是证据没读到、Runbook 没命中,还是模型生成阶段出问题。

二、运行参数

已有的 config.py 里放了路径和工具调用轮数。模型超时、重试和输出长度也放在这里,入口脚本只读取配置值。

修改文件:config.py

python
MODEL_TIMEOUT_SECONDS = 60
MODEL_MAX_RETRIES = 2
MODEL_RETRY_SLEEP_SECONDS = 2
MAX_OUTPUT_TOKENS = 1200

MODEL_TIMEOUT_SECONDS 控制一次请求等待多久。MODEL_MAX_RETRIESMODEL_RETRY_SLEEP_SECONDS 只处理临时网络错误、超时和 429 限流;参数错、认证失败、工具代码异常不靠重试解决。MAX_OUTPUT_TOKENS 限制报告长度,避免模型把 Runbook 原文大段抄进报告。

接着调整 scripts/run_agent.py 的导入区。文件里已经用到了 jsonrePathBASE_DIROUTPUT_DIR,现在把模型客户端、Pydantic 和重试相关异常也加进来。

修改文件:scripts/run_agent.py

python
import json
import os
import re
import sys
import time
from pathlib import Path

from openai import APIConnectionError, APITimeoutError, OpenAI, RateLimitError
from pydantic import BaseModel, Field, ValidationError

from config import (
    BASE_DIR,
    MAX_OUTPUT_TOKENS,
    MAX_STEPS,
    MAX_TOP_K,
    MODEL_MAX_RETRIES,
    MODEL_RETRY_SLEEP_SECONDS,
    MODEL_TIMEOUT_SECONDS,
    OUTPUT_DIR,
)

这个导入区和 14 的工具函数能接上。read_evidence()search_runbook()TOOLSdispatch_tool()build_instructions() 都还留在同一个文件里。

三、报告结构

模型直接写 Markdown 很省事,但程序不好检查。这里让模型返回 JSON,再由 Python 渲染成 Markdown。这样 report.md 的格式由代码控制,模型只负责填字段。

继续修改:scripts/run_agent.py

python
class LogReport(BaseModel):
    phenomenon: str = Field(description="日志里看到的故障现象")
    evidence: list[str] = Field(description="来自日志证据的关键事实")
    runbook_hits: list[str] = Field(default_factory=list, description="命中的 Runbook 条目")
    conclusion: str = Field(description="基于证据得到的判断")
    next_checks: list[str] = Field(default_factory=list, description="还需要人工继续确认的事项")

这几个字段和 log-triage Skill 的四块输出能对应上。phenomenon 是现象,evidence 是证据,runbook_hits 是 Runbook 命中,conclusion 是结论。next_checks 不一定每次都有,证据不足时才放需要继续查的东西。

Pydantic 只校验结构,不会判断结论是否真的正确。比如模型把 conclusion 写错了,Pydantic 仍然会通过。它解决的是字段缺失、类型不对、JSON 解析失败这些问题。

四、报告渲染

LogReport 是给程序用的对象,Markdown 是给人看的文件。渲染函数很普通,就是把字段排成固定标题。

继续修改:scripts/run_agent.py

python
def render_report(report: LogReport) -> str:
    lines = [
        "# 日志分析报告",
        "",
        "## 现象",
        "",
        report.phenomenon,
        "",
        "## 证据",
        "",
    ]

    for item in report.evidence:
        lines.append(f"- {item}")

    lines.extend(["", "## Runbook 命中", ""])
    if report.runbook_hits:
        for item in report.runbook_hits:
            lines.append(f"- {item}")
    else:
        lines.append("- 未命中")

    lines.extend(["", "## 结论", "", report.conclusion, "", "## 待确认", ""])
    if report.next_checks:
        for item in report.next_checks:
            lines.append(f"- {item}")
    else:
        lines.append("- 无")

    return "\n".join(lines).rstrip() + "\n"

模型不再决定 Markdown 标题怎么写。它返回结构化字段,Python 渲染成固定报告。报告样式以后要改,只改 render_report(),不用改提示词。

五、输出校验

模型返回的文本要经过两步:先按 JSON 解析,再按 LogReport 校验。任何一步失败,都把原始返回保存下来,不能只在终端打印一句错误。

继续修改:scripts/run_agent.py

python
def parse_report(raw_text: str) -> LogReport:
    try:
        data = json.loads(raw_text)
    except json.JSONDecodeError as exc:
        raise ValueError(f"报告不是合法 JSON: {exc}") from exc

    try:
        return LogReport.model_validate(data)
    except ValidationError as exc:
        raise ValueError(f"报告字段校验失败: {exc}") from exc

这种错误很常见。比如模型多写了一句说明:

text
以下是 JSON:
{"phenomenon": "..."}

json.loads() 会直接失败。再比如 JSON 合法,但字段写成了 summaryfacts,Pydantic 会提示缺少 phenomenonevidence。这类错误保存到 outputs/raw-report.txt,比在终端里滚过去好查。

六、模型请求

scripts/run_agent.py 已经有 TOOLSdispatch_tool()。现在补一个 call_model(),把模型请求集中到一个函数里,顺便处理超时、连接错误和限流重试。

继续修改:scripts/run_agent.py

python
def call_model(client: OpenAI, model: str, input_items: list):
    for attempt in range(MODEL_MAX_RETRIES + 1):
        try:
            return client.responses.create(
                model=model,
                instructions=build_report_instructions(),
                tools=TOOLS,
                input=input_items,
                max_output_tokens=MAX_OUTPUT_TOKENS,
            )
        except (RateLimitError, APIConnectionError, APITimeoutError) as exc:
            if attempt >= MODEL_MAX_RETRIES:
                raise
            sleep_seconds = MODEL_RETRY_SLEEP_SECONDS * (attempt + 1)
            print(
                f"模型请求失败,{sleep_seconds}s 后重试: {exc}",
                file=sys.stderr,
            )
            time.sleep(sleep_seconds)

这里只重试三类问题:429、连接错误、超时。认证失败、模型名错误、schema 写错都不该靠睡几秒解决;那类错误让脚本直接失败更清楚。

提示词也从一个函数里生成。它复用 build_instructions(),再补 JSON 输出格式。

继续修改:scripts/run_agent.py

python
def build_report_instructions() -> str:
    return (
        build_instructions()
        + "\n\n"
        + "输出要求:只输出 JSON,不输出 Markdown。"
        + "JSON 字段固定为 phenomenon、evidence、runbook_hits、conclusion、next_checks。"
        + "evidence 里的每一条都要能对应工具返回的日志证据。"
        + "资料不足时,conclusion 写证据不足,并把缺少的信息放到 next_checks。"
    )

这里没有把 Runbook 原文直接写进提示词。模型拿资料仍然要通过 read_evidencesearch_runbook,这样 trace 里才能留下工具调用过程。

七、工具循环

run_agent() 是入口脚本的核心。它保存输入历史,模型要工具时执行工具,工具结果再放回输入历史。模型不再调用工具时,把它的文本当成报告 JSON 解析。

继续修改:scripts/run_agent.py

python
def summarize_tool_result(result: dict) -> dict:
    summary = {"ok": result.get("ok", False)}
    if "error" in result:
        summary["error"] = result["error"]
    if "items" in result:
        summary["items"] = len(result["items"])
    return summary


def run_agent(client: OpenAI, model: str, question: str) -> dict:
    input_items = [
        {
            "role": "user",
            "content": question,
        }
    ]
    trace: list[dict] = []

    for step in range(1, MAX_STEPS + 1):
        response = call_model(client=client, model=model, input_items=input_items)
        input_items.extend(response.output)

        function_calls = [
            item for item in response.output
            if item.type == "function_call"
        ]

        if not function_calls:
            raw_text = response.output_text.strip()
            try:
                report = parse_report(raw_text)
            except ValueError as exc:
                return {
                    "ok": False,
                    "error": str(exc),
                    "raw_text": raw_text,
                    "trace": trace,
                    "stopped_by": "parse_error",
                }

            return {
                "ok": True,
                "report": report,
                "raw_text": raw_text,
                "trace": trace,
                "stopped_by": "answer",
            }

        for tool_call in function_calls:
            tool_result = dispatch_tool(tool_call)
            trace.append(
                {
                    "step": step,
                    "tool": tool_call.name,
                    "arguments": tool_call.arguments,
                    "result": summarize_tool_result(tool_result),
                }
            )

            input_items.append(
                {
                    "type": "function_call_output",
                    "call_id": tool_call.call_id,
                    "output": json.dumps(tool_result, ensure_ascii=False),
                }
            )

    return {
        "ok": False,
        "error": "工具调用次数超过上限",
        "raw_text": "",
        "trace": trace,
        "stopped_by": "max_steps",
    }

trace 里没有保存完整工具结果,只保存 okerroritems 数量。完整证据本来就在 outputs/evidence.jsonoutputs/runbook-index.json 里,trace 主要说明模型有没有调工具、调了哪个工具、参数是什么、返回大概是否正常。

八、结果保存

save_run() 负责把运行结果落到 outputs/。成功时写 report.mdrun-trace.json;解析失败或工具循环失败时,写 raw-report.txtrun-trace.json

继续修改:scripts/run_agent.py

python
def save_run(result: dict) -> dict[str, Path]:
    OUTPUT_DIR.mkdir(parents=True, exist_ok=True)

    trace_path = OUTPUT_DIR / "run-trace.json"
    trace_payload = {
        "ok": result["ok"],
        "stopped_by": result["stopped_by"],
        "error": result.get("error"),
        "tools": result["trace"],
    }
    trace_path.write_text(
        json.dumps(trace_payload, ensure_ascii=False, indent=2),
        encoding="utf-8",
    )

    paths = {"trace": trace_path}

    if result["ok"]:
        report_path = OUTPUT_DIR / "report.md"
        report_path.write_text(
            render_report(result["report"]),
            encoding="utf-8",
        )
        paths["report"] = report_path
    else:
        raw_path = OUTPUT_DIR / "raw-report.txt"
        raw_path.write_text(result.get("raw_text", ""), encoding="utf-8")
        paths["raw"] = raw_path

    return paths

run-trace.json 不管成功失败都写。一次失败运行也有价值,尤其是解析失败时,raw-report.txt 和 trace 放在一起才能看出模型是没按 JSON 输出,还是工具资料本身不够。

九、命令入口

main() 做四件事:检查环境变量,创建客户端,运行 Agent,保存结果。问题文本固定成“分析当前日志异常,输出报告”,也可以用环境变量覆盖。

继续修改:scripts/run_agent.py

python
def get_required_env(name: str) -> str | None:
    value = os.getenv(name)
    if not value:
        print(f"缺少环境变量: {name}", file=sys.stderr)
        return None
    return value


def main() -> int:
    api_key = get_required_env("OPENAI_API_KEY")
    model = get_required_env("OPENAI_MODEL")
    if not api_key or not model:
        return 2

    question = os.getenv("OPS_LOG_AGENT_QUESTION", "分析当前日志异常,输出报告。")
    client = OpenAI(api_key=api_key, timeout=MODEL_TIMEOUT_SECONDS)

    result = run_agent(client=client, model=model, question=question)
    paths = save_run(result)

    print(f"trace: {paths['trace']}")
    if result["ok"]:
        print(f"report: {paths['report']}")
        return 0

    print(f"运行失败: {result.get('error')}", file=sys.stderr)
    if "raw" in paths:
        print(f"raw: {paths['raw']}", file=sys.stderr)
    return 1


if __name__ == "__main__":
    raise SystemExit(main())

这里的退出码也有用。0 表示报告生成成功,1 表示模型调用或报告解析阶段失败,2 表示环境变量缺失。定时任务或 CI 里看退出码,比只看终端文字稳。

十、样本回放

项目根目录下按顺序执行:

bash
uv run python -m scripts.read_logs
uv run python -m scripts.build_index
uv run python -m scripts.run_agent

没有配置 API key 时,第三步会停在环境变量检查:

text
缺少环境变量: OPENAI_API_KEY
缺少环境变量: OPENAI_MODEL

PowerShell 里可以这样设置:

powershell
$env:OPENAI_API_KEY="你的 API Key"
$env:OPENAI_MODEL="模型名"
uv run python -m scripts.run_agent

本机网络需要代理时,代理放在运行环境里,不写进代码:

powershell
$env:HTTP_PROXY="http://127.0.0.1:7890"
$env:HTTPS_PROXY="http://127.0.0.1:7890"

成功时能看到两个路径:

text
trace: .../ops-log-agent/outputs/run-trace.json
report: .../ops-log-agent/outputs/report.md

report.md 里的内容大概会是这种形状:

markdown
# 日志分析报告

## 现象

订单接口出现 502,后台订单同步任务出现 upstream timeout。

## 证据

- api WARN /orders status=502,request_id=req-1002,cost_ms=2100
- worker ERROR task=sync_orders,order_id=8802,message=upstream timeout

## Runbook 命中

- common.md / 订单同步失败

## 结论

当前证据指向订单同步链路的上游超时。

## 待确认

- 查看上游服务日志和 worker 重试记录

具体文字会随模型变化,但几个标题和字段应该稳定。报告没有命中 Runbook 时,看 run-trace.jsonsearch_runbook 的参数和结果数量。

十一、结果检查

运行结束后先看文件是否齐:

text
outputs/
  evidence.json
  runbook-index.json
  report.md
  run-trace.json

evidence.json 里应该有两组证据,runbook-index.json 里应该有两个 section,run-trace.json 里至少应该出现一次 read_evidence。如果 trace 里没有 search_runbook,说明模型只读了证据,没有拿证据去查 Runbook。

比较容易定位的几种情况:

  • evidence.json 不存在:scripts.read_logs 没跑,或者日志路径错了。
  • runbook-index.json 不存在:scripts.build_index 没跑,或者 runbooks/ 里没有 Markdown。
  • run-trace.jsonread_evidence 返回 ok=false:入口脚本能跑,但前置文件缺失。
  • raw-report.txt 有内容:模型返回了文本,但 JSON 解析或 Pydantic 校验失败。
  • report.md 证据和原始日志对不上:看 trace 里的工具调用,再回到 evidence.json 核对字段。

这块其实是 Agent 项目最容易被忽略的地方。只看最后那段回答会很省事,但排错时完全不够。工具调用、参数、返回数量和原始输出都留一点痕迹,问题才不会变成“模型刚才到底看了什么”。

十二、运行限制

当前项目里有几层限制:

  • MAX_STEPS 控制工具调用轮数。
  • MAX_TOP_K 控制 Runbook 返回条数。
  • MAX_OUTPUT_TOKENS 控制模型输出长度。
  • MODEL_TIMEOUT_SECONDS 控制单次请求超时。
  • MODEL_MAX_RETRIES 控制临时失败重试次数。

这些值不需要一开始调得很大。sample.log 只有 4 行,Runbook 只有 2 个章节,工具调用超过 4 轮就很可疑。比如 trace 里连续三次调用 read_evidence,大概率是提示词或工具描述让模型卡住了,调大 MAX_STEPS 只会多花请求。

429 限流和网络超时可以短暂重试。认证失败、模型名不存在、JSON schema 写错、工具返回缺文件,这些都不是重试能解决的。终端里看到 缺少 outputs/evidence.json,该跑的是 scripts.read_logs,不是把重试次数调大。

十三、定时运行

定时运行时,三步命令还是一样。Linux 上可以写到 cron 或 systemd timer,Windows 上可以放到任务计划程序。定时任务最重要的是工作目录、环境变量和日志输出。

比如 Linux cron 里可以写成:

cron
*/10 * * * * cd /opt/ops-log-agent && uv run python -m scripts.read_logs && uv run python -m scripts.build_index && uv run python -m scripts.run_agent >> outputs/last-run.log 2>&1

PowerShell 里可以用一个很短的运行脚本:

powershell
Set-Location "D:\ops-log-agent"
uv run python -m scripts.read_logs
uv run python -m scripts.build_index
uv run python -m scripts.run_agent

定时任务里只看有没有 report.md 容易误判。如果上一轮成功生成过报告,下一轮失败但没有清理旧文件,report.md 还在,看起来像运行正常。更稳的检查是看 run-trace.json 的修改时间、ok 字段和终端日志里的退出码。

项目跑到这里,ops-log-agent 已经有完整的一次本地流程:读日志,提证据,建 Runbook 索引,让模型通过工具拿资料,生成结构化报告,再把报告和 trace 写入 outputs/