adaptive-log-parser¶
第5章 · Coding Agent 与代码生成 · 配套项目
chapter5/adaptive-log-parser
项目说明¶
自适应的日志解析系统(实验 5-7)¶
《深入理解 AI Agent》第 5 章「代码作为系统适配器」配套实验:一个能自我进化的 Agent 日志解析系统。系统初始只支持基础日志格式;遇到无法解析的新格式时,不是报错, 而是自动把失败样本 + 报错交给 Agent,让它生成能正确解析的代码,自动测试通过后热更新 注册进解析系统。全流程自动化,无需人工介入。
自愈闭环¶
一行日志
│
▼
[解析引擎] 依次尝试已注册的解析器
│
├── 有解析器认识 → 输出结构化字段 ✅
│
└── 全部失败(检测到新格式)❌
│ 失败样本 + 报错
▼
[代码生成 Agent] ← OpenAI(gpt-5.6-luna)
│ 生成 def parse(line)->dict|None
▼
[自动测试] 数据结构断言(tester.py)
│
├── 不通过 → 把失败报告反馈给 Agent 重试(最多 3 次)
│
└── 通过 → [热加载注册] + 持久化到 parsers/*.py
│
▼
系统现在能正确解析该新格式 ✅(下次重启直接复用,不再问 Agent)
对应代码:
- engine.py:解析引擎 + 解析器注册表 + 热加载(importlib)。内置 builtin_json_parser。
- agent.py:代码生成 Agent,调用 OpenAI 生成解析函数,支持带失败反馈迭代修复。
- tester.py:自动测试,对生成的 parse 函数做数据结构断言。
- demo.py:串起整条闭环并逐步打印。
- parsers/:Agent 学会的解析器持久化到这里,供下次直接复用。
演示的三种递进格式¶
- 基础 JSON 行(系统原生支持):
{"timestamp": "...", "level": "INFO", "message": "..."} - 新格式 A —— 自定义竖线分隔:
2026-07-17T10:23:01Z|INFO|agent.planner|step=3|Generated plan... - 新格式 B —— 嵌套括号:
[2026-07-17 10:24:55] (ERROR) <tool=web_search> {latency_ms=812 status=timeout} :: ...
格式 A、B 初次解析都会失败,触发 Agent 生成解析器 → 自动测试通过 → 热更新后能正确解析。
运行¶
pip install -r requirements.txt
cp env.example .env # 填入 OPENAI_API_KEY(默认模型 gpt-5.6-luna);未配置时设 OPENROUTER_API_KEY 自动改走 OpenRouter
python demo.py # 完整演示(两种新格式,两次真实 Agent 调用,需 API Key)
python demo.py --offline # 离线演示:用预置解析器跑完整机制,无需 API Key
python demo.py --quick # 快速模式:只演示 1 种新格式,省一次 API 调用
python demo.py --log-file logs.txt # 步骤 3 改用外部日志文件(每行一条)验证复用
python demo.py --output out.jsonl # 把解析出的结构化结果写成 JSONL
python demo.py --help # 查看全部参数
命令行参数:
| 参数 | 说明 |
|---|---|
--offline |
用预置(canned)解析器代码代替调用 OpenAI,无需 API Key,确定性地演示整条机制(失败检测→生成→测试→热重载→持久化)。 |
--quick |
只演示 1 种新格式(竖线分隔),跳过嵌套括号格式,省一次 Agent/API 调用。 |
--model MODEL |
覆盖代码生成模型;默认读 MODEL 环境变量再回落 gpt-5.6-luna。--offline 下仅作展示。 |
--log-file PATH |
外部日志文件(每行一条)。给定后步骤 3 改用学到的解析系统解析该文件,替代内置混合样本,验证解析器可复用到真实日志流。 |
--output PATH |
把步骤 3 解析出的结构化结果以 JSONL(每行一条 JSON)写入该文件。 |
demo.py 默认真实调用 OpenAI,依次演示:(a) 新格式初次解析失败被检测到;
(b) Agent 生成解析代码并通过自动测试;(c) 热更新后系统正确解析该新格式并打印结构化结果;
最后新建一个引擎,直接从 parsers/ 加载已学会的解析器,验证持久化复用(不再调用 Agent)。
没有 API Key 时用 --offline:离线模式换用 agent.py 里的 OfflineCodeGenAgent,它按必需字段
查表返回预写好的解析器源码(并非真让 LLM 现写),但失败检测→自动测试→热加载注册→持久化这些
运行时机制与在线模式完全一致,可完整跑通并验证闭环。
预期输出示例(真实运行片段)¶
以下摘自一次真实运行(python demo.py,模型 gpt-5.6-luna):
步骤 1:遇到新格式 A —— 自定义竖线分隔格式
(a) 先让系统解析,预期【失败】:
❌ 解析失败:2026-07-17T10:23:01Z|INFO|agent.planner|step=3|Generated plan with 5 actions
触发自愈闭环:
🔎 检测到无法解析的新格式,触发自愈。报错:没有任何已注册解析器能解析该行:...
--- 第 1/3 次:Agent 生成解析代码 ---
| import re
| _PATTERN = re.compile(
| r"^\s*"
| r"(?P<timestamp>\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z)"
| r"\s*\|\s*(?P<level>[A-Za-z]+)\s*\|\s*(?P<module>[^|]+?)"
| r"\s*\|\s*step\s*=\s*(?P<step>\d+)\s*\|\s*(?P<message>\S(?:.*\S)?)\s*$"
| )
| def parse(line: str) -> dict | None:
| match = _PATTERN.match(line)
| if not match:
| return None
| fields = match.groupdict()
| fields["module"] = fields["module"].strip()
| fields["message"] = fields["message"].strip()
| fields["step"] = int(fields["step"])
| return fields
🧪 自动测试(数据结构断言):
[样本1] 通过,解析出字段:['level', 'message', 'module', 'step', 'timestamp']
✅ 自动测试通过,已热更新注册解析器 'pipe_parser' 并持久化到 parsers/pipe_parser.py
(c) 热更新后重新解析同样的日志,预期【成功】:
✅ [pipe_parser] {'_parser': 'pipe_parser', 'timestamp': '2026-07-17T10:23:01Z',
'level': 'INFO', 'module': 'agent.planner', 'step': 3, 'message': 'Generated plan with 5 actions'}
...
演示结束
新格式 A(竖线分隔)自愈结果:成功
新格式 B(嵌套括号)自愈结果:成功
持久化复用(混合格式全部解析):成功
LLM 生成的代码每次可能略有不同(如变量名、正则写法),但只要通过自动测试即视为成功。 若用
python demo.py --offline,预置解析器让输出确定性复现上述闭环(无需 API Key)。
如何适配 / 扩展¶
- 换模型 / 供应商:本项目统一走 OpenAI 兼容协议,改环境变量即可,无需改代码。
MODEL:换模型,例如MODEL=gpt-5.6;也可在命令行用python demo.py --model gpt-5.6临时覆盖。OPENAI_BASE_URL:换成任意 OpenAI 兼容端点(如自建网关、Moonshot/火山方舟等), 再把OPENAI_API_KEY换成对应服务的 key、MODEL换成该服务的模型名即可。- 三者的读取逻辑集中在
agent.py的CodeGenAgent.__init__。 - 换输入日志格式:在
demo.py里按现有PIPE_LOGS/BRACKET_LOGS的写法,加一组 你自己的样本(XXX_LOGS)和必需字段列表(XXX_REQUIRED),再调一次self_heal(engine, agent, "your_parser", XXX_LOGS, XXX_REQUIRED)即可让系统自学。required_keys决定自动测试的验收标准(哪些字段必须被解析出且非空)。 - 接入真实日志流:把
engine.parse_line(line)接到你的日志读取循环上;捕获ParseError即触发自愈闭环。已学会的解析器持久化在parsers/*.py,重启后由engine.load_persisted()自动加载复用。
局限与说明¶
- 可视化验证降级:书中原方案是把生成的可视化代码放进虚拟浏览器渲染,再用 Vision LLM 检查渲染效果。本机没有 playwright/浏览器环境,因此把这一步降级为对 生成的解析函数做数据结构断言(用样本数据断言解析出的结构化字段正确)。核心闭环 (检测失败 → 生成解析代码 → 自动测试 → 热加载注册新解析器 → 持久化复用)是真实实现的。
- 安全性:Agent 生成的代码通过
importlib直接执行,仅适用于可信实验环境;生产中应 加沙箱、AST 白名单、资源限制等隔离手段。系统提示已约束只用标准库、无副作用。 - 确定性:LLM 生成代码存在不确定性,故设置了「测试不通过→带反馈重试」的迭代修复 (最多 3 次);仍可能失败,属正常现象,重跑即可。
源代码¶
agent.py¶
"""
agent.py —— 代码生成 Agent(自愈闭环的“大脑”)
职责:拿到无法解析的失败样本 + 报错,调用 OpenAI,生成一个能正确解析该格式的
Python 解析函数 `def parse(line: str) -> dict | None`。支持把上一轮自动测试的
失败报告作为反馈再次生成(迭代修复)。
"""
from __future__ import annotations
import os
import re
from typing import List, Optional
from openai import OpenAI
# .env 加载(可选依赖)
try:
from dotenv import load_dotenv
load_dotenv()
except Exception:
pass
SYSTEM_PROMPT = """你是一个"日志解析器代码生成器"。用户会给你一批**同一种未知格式**的日志样本,
以及现有系统解析失败的报错。你的任务:编写一个 Python 函数,把这种格式的每一行解析成结构化字段。
严格要求:
1. 只输出一个 Python 代码块(```python ... ```),不要任何解释文字。
2. 代码块里必须定义一个函数:def parse(line: str) -> dict | None
- 输入是一行日志(字符串)。
- 如果这行符合你要解析的格式,返回一个 dict,键为字段名(英文小写下划线),值为解析出的内容。
- 如果这行**不符合**这种格式,必须返回 None(不要抛异常,把机会让给其它解析器)。
3. 只能使用 Python 标准库(re、json、datetime 等),不要 import 第三方库。
4. 不要有任何 print、input、文件读写、网络访问等副作用。
5. 必须解析出用户指定的**所有必需字段**(required_keys),字段值不能为空。
6. 尽量健壮:用正则/分隔符解析,容忍字段顺序内的空格。
"""
def _build_user_prompt(
samples: List[str],
required_keys: List[str],
error_report: str,
feedback: Optional[str],
) -> str:
sample_block = "\n".join(samples)
parts = [
"现有系统无法解析下面这种格式的日志,请生成解析函数。",
"",
"【失败样本(同一种新格式)】",
sample_block,
"",
f"【系统报错】\n{error_report}",
"",
f"【必需解析出的字段 required_keys】\n{required_keys}",
]
if feedback:
parts += [
"",
"【上一版代码没通过自动测试,请修复后重新生成】",
feedback,
]
return "\n".join(parts)
def _extract_code(text: str) -> str:
"""从模型回复中抽取 Python 代码块;没有围栏时退回整段文本。"""
m = re.search(r"```(?:python)?\s*(.*?)```", text, re.DOTALL)
return (m.group(1) if m else text).strip()
OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1"
def _map_to_openrouter_model(model: str) -> str:
"""把直连模型名映射为 OpenRouter 上的 id(非可映射 id 统一兜底到当前廉价旗舰)。"""
if not model or "/" in model:
return model or "openai/gpt-5.6-luna"
m = model.lower()
if m.startswith(("gpt-", "o1", "o3", "o4")):
return "openai/" + model
if m.startswith("claude"):
if "haiku" in m:
return "anthropic/claude-haiku-4.5"
if "sonnet" in m:
return "anthropic/claude-sonnet-4.6"
return "anthropic/claude-opus-4.8"
if m.startswith("gemini"):
return "google/" + model
return "openai/gpt-5.6-luna"
class CodeGenAgent:
def __init__(self, model: Optional[str] = None):
model = model or os.getenv("MODEL", "gpt-5.6-luna")
api_key = os.getenv("OPENAI_API_KEY")
base_url = os.getenv("OPENAI_BASE_URL")
orkey = os.getenv("OPENROUTER_API_KEY")
# 通用 OpenRouter 兜底:无直连 key,或默认 gpt-5.x(直连需组织实名认证)时改走 OpenRouter。
prefer_or = bool(orkey) and (model or "").lower().startswith("gpt-5")
if prefer_or or (not api_key and orkey):
api_key, base_url, model = orkey, OPENROUTER_BASE_URL, _map_to_openrouter_model(model)
if not api_key:
raise SystemExit("未找到 OPENAI_API_KEY(或 OPENROUTER_API_KEY 兜底),请在环境变量或 .env 中设置。")
# timeout / max_retries:让偶发的网络/SSL 抖动自动重试,不至于整轮崩溃
client_kwargs = {"api_key": api_key, "timeout": 60.0, "max_retries": 3}
if base_url:
client_kwargs["base_url"] = base_url
self.client = OpenAI(**client_kwargs)
self.model = model
def generate_parser_code(
self,
samples: List[str],
required_keys: List[str],
error_report: str,
feedback: Optional[str] = None,
) -> str:
"""调用 LLM 生成解析器代码,返回纯 Python 源码字符串。"""
user_prompt = _build_user_prompt(samples, required_keys, error_report, feedback)
# 推理模型(gpt-5 / o 系列等)不接受 temperature=0。
_reasoning = any(k in (self.model or "").lower()
for k in ("gpt-5", "o1", "o3", "o4", "thinking", "reasoner", "kimi-k3"))
resp = self.client.chat.completions.create(
model=self.model,
temperature=1 if _reasoning else 0,
messages=[
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": user_prompt},
],
)
return _extract_code(resp.choices[0].message.content or "")
# ---------------------------------------------------------------------------
# 离线(无 API)代码生成 Agent
# ---------------------------------------------------------------------------
# 与 CodeGenAgent 接口完全一致,但不调用 OpenAI,而是根据必需字段返回**预置**的
# 解析器源码。它的用途是:在没有 API Key 的环境里,仍能确定性地演示与验证整条
# 机制——失败检测 → (预置)生成代码 → 自动测试 → 热加载注册 → 持久化复用。
# 注意:这里的“生成”是查表返回预写好的代码,并非真正让 LLM 现写;只有换用
# CodeGenAgent 才是真正的代码生成。
_CANNED_PARSERS = {
# 竖线分隔格式:时间戳|级别|模块|step=N|消息
frozenset(["timestamp", "level", "module", "step", "message"]): '''import re
def parse(line: str) -> dict | None:
pattern = (
r"^(?P<timestamp>\\S+)\\|(?P<level>\\S+)\\|(?P<module>\\S+)"
r"\\|step=(?P<step>\\d+)\\|(?P<message>.+)$"
)
match = re.match(pattern, line.strip())
if match:
return match.groupdict()
return None
''',
# 嵌套括号格式:[时间] (级别) <tool=名字> {k=v k=v} :: 消息
frozenset(["timestamp", "level", "tool", "message"]): '''import re
def parse(line: str) -> dict | None:
pattern = (
r"\\[(?P<timestamp>.*?)\\] \\((?P<level>.*?)\\) <tool=(?P<tool>.*?)> "
r"\\{latency_ms=(?P<latency_ms>\\d+) status=(?P<status>\\w+)\\} :: (?P<message>.*)"
)
match = re.match(pattern, line.strip())
if match:
return match.groupdict()
return None
''',
}
class OfflineCodeGenAgent:
"""离线桩:查表返回预置解析器代码,接口与 CodeGenAgent 一致(无需 API Key)。"""
def __init__(self, model: Optional[str] = None):
self.model = model or "offline-canned"
def generate_parser_code(
self,
samples: List[str],
required_keys: List[str],
error_report: str,
feedback: Optional[str] = None,
) -> str:
key = frozenset(required_keys)
code = _CANNED_PARSERS.get(key)
if code is not None:
return code
# 未预置该格式:返回一个永远返回 None 的桩,让自动测试如实失败,
# 从而演示“测试未通过 → 放弃该格式”的分支(离线模式无法真正现写代码)。
return (
"def parse(line: str) -> dict | None:\n"
" # 离线模式未预置该格式的解析器\n"
" return None\n"
)
demo.py¶
"""
demo.py —— 自适应日志解析系统:自愈闭环演示
演示整条自愈流程(全流程自动化):
初始系统只认基础 JSON 日志 →
遇到没见过的新格式 → 解析【失败】被检测到 →
把失败样本 + 报错交给 Agent → Agent【生成解析代码】→
【自动测试】(数据结构断言)→ 通过后【热加载注册 + 持久化】→
系统【正确解析】了新格式。
运行:
python demo.py # 完整演示(两种新格式,两次 Agent 调用,需 API Key)
python demo.py --offline # 离线演示:用预置解析器跑完整机制,无需 API Key
python demo.py --quick # 快速模式:只演示 1 种新格式,省一次 API 调用
python demo.py --help # 查看全部参数
命令行参数见文件底部的 build_arg_parser()。
"""
from __future__ import annotations
import argparse
import json
import os
import textwrap
from typing import List, Tuple
from engine import LogParserEngine, ParseError, builtin_json_parser
from agent import CodeGenAgent, OfflineCodeGenAgent
from tester import run_tests
HERE = os.path.dirname(os.path.abspath(__file__))
PARSERS_DIR = os.path.join(HERE, "parsers")
MAX_ATTEMPTS = 3 # Agent 生成→测试的最大迭代修复次数
# ---------------------------------------------------------------------------
# 演示用的三种递进日志格式
# ---------------------------------------------------------------------------
# 格式 1:基础 JSON 行 —— 初始系统就支持
JSON_LOGS = [
'{"timestamp": "2026-07-17T10:22:31Z", "level": "INFO", "message": "Agent started task planning"}',
'{"timestamp": "2026-07-17T10:22:33Z", "level": "DEBUG", "message": "Loaded 12 tools into context"}',
]
# 格式 2:自定义竖线分隔格式 —— Agent 没见过
# 时间戳|级别|模块|step=N|消息
PIPE_LOGS = [
"2026-07-17T10:23:01Z|INFO|agent.planner|step=3|Generated plan with 5 actions",
"2026-07-17T10:23:04Z|WARNING|agent.executor|step=4|Tool call retried once",
"2026-07-17T10:23:07Z|ERROR|agent.executor|step=5|Tool web_search returned empty result",
]
PIPE_REQUIRED = ["timestamp", "level", "module", "step", "message"]
# 格式 3:嵌套括号格式 —— Agent 也没见过
# [时间] (级别) <tool=名字> {k=v k=v} :: 消息
BRACKET_LOGS = [
"[2026-07-17 10:24:55] (ERROR) <tool=web_search> {latency_ms=812 status=timeout} :: upstream request failed",
"[2026-07-17 10:25:01] (INFO) <tool=code_run> {latency_ms=134 status=ok} :: executed snippet successfully",
"[2026-07-17 10:25:09] (WARN) <tool=file_read> {latency_ms=45 status=partial} :: file truncated at 1MB",
]
BRACKET_REQUIRED = ["timestamp", "level", "tool", "message"]
# ---------------------------------------------------------------------------
# 小工具
# ---------------------------------------------------------------------------
def hr(title: str = "") -> None:
print("\n" + "=" * 78)
if title:
print(title)
print("=" * 78)
def try_parse_all(
engine: LogParserEngine, logs: List[str]
) -> Tuple[bool, List[dict]]:
"""尝试解析一批日志,打印结果;返回 (是否全部成功, 成功解析出的结构化记录列表)。"""
all_ok = True
records: List[dict] = []
for line in logs:
try:
result = engine.parse_line(line)
records.append(result)
print(f" ✅ [{result['_parser']}] {result}")
except ParseError:
all_ok = False
print(f" ❌ 解析失败:{line}")
return all_ok, records
def read_log_file(path: str) -> List[str]:
"""从外部日志文件读取日志(每行一条,忽略空行)。"""
with open(path, "r", encoding="utf-8") as f:
return [line.rstrip("\n") for line in f if line.strip()]
def write_output(path: str, records: List[dict]) -> None:
"""把解析出的结构化记录写成 JSONL(每行一条 JSON)。"""
with open(path, "w", encoding="utf-8") as f:
for rec in records:
f.write(json.dumps(rec, ensure_ascii=False) + "\n")
# ---------------------------------------------------------------------------
# 自愈闭环:检测失败 → 生成 → 测试 → 热更新
# ---------------------------------------------------------------------------
def self_heal(
engine: LogParserEngine,
agent: "CodeGenAgent | OfflineCodeGenAgent",
parser_name: str,
samples: List[str],
required_keys: List[str],
) -> bool:
"""针对一种新格式跑完整的自愈闭环,成功注册返回 True。"""
# (a) 触发原因:拿一条样本让系统解析,确认确实失败
failing_line = samples[0]
try:
engine.parse_line(failing_line)
print(" (该格式已能解析,无需自愈)")
return True
except ParseError as exc:
error_report = str(exc)
print(f" 🔎 检测到无法解析的新格式,触发自愈。报错:{error_report}")
target_path = os.path.join(PARSERS_DIR, f"{parser_name}.py")
feedback = None
for attempt in range(1, MAX_ATTEMPTS + 1):
print(f"\n --- 第 {attempt}/{MAX_ATTEMPTS} 次:Agent 生成解析代码 ---")
code = agent.generate_parser_code(
samples=samples,
required_keys=required_keys,
error_report=error_report,
feedback=feedback,
)
print(textwrap.indent(code, " | "))
# 写入候选文件(parsers/),再热加载
with open(target_path, "w", encoding="utf-8") as f:
f.write(code)
# 热加载生成的 parse 函数
try:
fn = LogParserEngine.load_parser_from_file(target_path)
except Exception as exc:
feedback = f"代码无法导入/执行:{type(exc).__name__}: {exc}"
print(f" ⚠️ 热加载失败:{feedback}")
continue
# (b) 自动测试:数据结构断言
print(" 🧪 自动测试(数据结构断言):")
test = run_tests(fn, samples, required_keys)
print(textwrap.indent(test["report"], " "))
if test["passed"]:
# (c) 通过 → 热更新注册进引擎,文件已持久化到 parsers/
engine.register(parser_name, fn)
print(f" ✅ 自动测试通过,已热更新注册解析器 '{parser_name}' 并持久化到 parsers/{parser_name}.py")
return True
feedback = "自动测试未通过,失败详情如下:\n" + test["report"]
print(" ↻ 测试未通过,把失败报告反馈给 Agent 重试。")
# 全部尝试失败:删除无效文件
if os.path.exists(target_path):
os.remove(target_path)
print(f" ❌ {MAX_ATTEMPTS} 次尝试后仍未通过,放弃该格式。")
return False
# ---------------------------------------------------------------------------
# 主流程
# ---------------------------------------------------------------------------
def main(args: argparse.Namespace) -> None:
hr("自适应日志解析系统 —— 自愈闭环演示(实验 5-7)")
print("初始系统只内置一个基础解析器:JSON 行解析器。")
if args.quick:
print("(--quick 快速模式:仅演示 1 种新格式,省一次 Agent/API 调用)")
if args.offline:
print("(--offline 离线模式:用预置解析器代替 OpenAI,无需 API Key,机制完全一致)")
os.makedirs(PARSERS_DIR, exist_ok=True) # 确保持久化目录存在(新克隆时可能只有 .gitkeep)
engine = LogParserEngine()
engine.register("builtin_json", builtin_json_parser)
print(f"当前已注册解析器:{engine.parser_names}")
# model=None 时回落到 MODEL 环境变量/默认 gpt-5.6-luna;离线模式不触碰 API
agent = OfflineCodeGenAgent(args.model) if args.offline else CodeGenAgent(model=args.model)
print(f"代码生成 Agent 使用模型:{agent.model}")
# 步骤 0:基础 JSON 格式,系统本来就能解析
hr("步骤 0:解析基础 JSON 日志(系统原生支持)")
try_parse_all(engine, JSON_LOGS)
# 步骤 1:自定义竖线分隔格式(Agent 没见过)
hr("步骤 1:遇到新格式 A —— 自定义竖线分隔格式")
print("原始日志样本:")
for l in PIPE_LOGS:
print(f" {l}")
print("\n(a) 先让系统解析,预期【失败】:")
try_parse_all(engine, PIPE_LOGS)
print("\n触发自愈闭环:")
ok1 = self_heal(engine, agent, "pipe_parser", PIPE_LOGS, PIPE_REQUIRED)
if ok1:
print("\n(c) 热更新后重新解析同样的日志,预期【成功】:")
try_parse_all(engine, PIPE_LOGS)
# 步骤 2:嵌套括号格式(Agent 也没见过)—— 快速模式下跳过,省一次 API 调用
ok2 = None
if args.quick:
hr("步骤 2:(--quick 模式已跳过新格式 B 的演示)")
else:
hr("步骤 2:遇到新格式 B —— 嵌套括号格式")
print("原始日志样本:")
for l in BRACKET_LOGS:
print(f" {l}")
print("\n(a) 先让系统解析,预期【失败】:")
try_parse_all(engine, BRACKET_LOGS)
print("\n触发自愈闭环:")
ok2 = self_heal(engine, agent, "bracket_parser", BRACKET_LOGS, BRACKET_REQUIRED)
if ok2:
print("\n(c) 热更新后重新解析同样的日志,预期【成功】:")
try_parse_all(engine, BRACKET_LOGS)
# 步骤 3:验证持久化复用 —— 新引擎直接加载 parsers/,无需再问 Agent
hr("步骤 3:验证持久化复用(重启系统,直接加载已学会的解析器)")
engine2 = LogParserEngine()
engine2.register("builtin_json", builtin_json_parser)
loaded = engine2.load_persisted(PARSERS_DIR)
print(f"新引擎从 parsers/ 热加载了:{loaded}")
if args.log_file:
print(f"用学到的解析系统解析外部日志文件(不再调用 Agent):{args.log_file}")
mixed = read_log_file(args.log_file)
else:
print("直接解析之前的新格式(不再调用 Agent):")
mixed = [JSON_LOGS[0], PIPE_LOGS[0]]
if not args.quick:
mixed.append(BRACKET_LOGS[0]) # 快速模式没生成 bracket_parser,混合样本里也不放它
all_ok, records = try_parse_all(engine2, mixed)
if args.output:
write_output(args.output, records)
print(f"已将 {len(records)} 条结构化解析结果写入(JSONL):{args.output}")
hr("演示结束")
print(f"新格式 A(竖线分隔)自愈结果:{'成功' if ok1 else '失败'}")
if ok2 is None:
print("新格式 B(嵌套括号):--quick 模式已跳过")
else:
print(f"新格式 B(嵌套括号)自愈结果:{'成功' if ok2 else '失败'}")
print(f"持久化复用(混合格式全部解析):{'成功' if all_ok else '失败'}")
print(f"已学会并持久化的解析器目录:{PARSERS_DIR}")
def build_arg_parser() -> argparse.ArgumentParser:
"""构造命令行参数解析器(提供 --help / --quick / --model)。"""
parser = argparse.ArgumentParser(
description="自适应日志解析系统:自愈闭环演示(检测失败 → Agent 生成解析代码 → "
"自动测试 → 热加载注册 → 持久化复用)。默认走 OpenAI,需 OPENAI_API_KEY;"
"加 --offline 用预置解析器演示同一套机制,无需 API Key。",
formatter_class=argparse.RawDescriptionHelpFormatter,
)
parser.add_argument(
"--offline",
action="store_true",
help="离线模式:用预置(canned)解析器代码代替调用 OpenAI,无需 API Key,"
"确定性地演示“失败检测→生成→测试→热重载→持久化”整条机制。",
)
parser.add_argument(
"--quick",
action="store_true",
help="快速模式:只演示 1 种新格式(竖线分隔),跳过嵌套括号格式,省一次 Agent/API 调用。",
)
parser.add_argument(
"--model",
default=None,
help="覆盖代码生成使用的模型;默认读取环境变量 MODEL,再回落到 gpt-5.6-luna。"
"(--offline 下此项仅作展示,不影响预置解析器。)",
)
parser.add_argument(
"--log-file",
default=None,
metavar="PATH",
help="外部日志文件路径(每行一条日志)。给定后,步骤 3 改用学到的解析系统解析"
"该文件,替代内置混合样本;用于验证学到的解析器可复用到真实日志流。",
)
parser.add_argument(
"--output",
default=None,
metavar="PATH",
help="把步骤 3 解析出的结构化结果以 JSONL(每行一条 JSON)写入该文件。",
)
return parser
if __name__ == "__main__":
main(build_arg_parser().parse_args())
engine.py¶
"""
engine.py —— 自适应日志解析引擎(自愈闭环的“运行时”)
设计要点:
- 引擎维护一个**解析器注册表**(有序列表)。每来一行日志,依次尝试每个解析器,
谁能解析(返回非空 dict)就用谁的结果;全部失败则抛出 ParseError —— 这就是
“前端检测到无法解析的新格式”的信号,触发后续的自愈流程。
- 每个解析器就是一个纯函数 `parse(line: str) -> dict | None`:
* 能解析 → 返回结构化字段(dict)
* 不认识这行 → 返回 None(把机会让给别的解析器,避免“抢答”)
- 生成的解析器可以持久化成 parsers/*.py 模块,下次启动直接热加载复用,无需再问 Agent。
注意:这里对“可视化”做了降级——书中用虚拟浏览器 + Vision LLM 验证渲染效果,
本项目改为对解析函数做**数据结构断言**(见 tester.py),核心自愈闭环是真实实现的。
"""
from __future__ import annotations
import importlib.util
import json
import os
from typing import Callable, Dict, List, Optional, Tuple
# 一个解析器 = (名字, 解析函数)
ParserFn = Callable[[str], Optional[Dict]]
class ParseError(Exception):
"""所有已注册解析器都无法解析该行时抛出,携带原始样本供 Agent 分析。"""
def __init__(self, line: str):
self.line = line
super().__init__(f"没有任何已注册解析器能解析该行:{line!r}")
def builtin_json_parser(line: str) -> Optional[Dict]:
"""内置的基础解析器:只认标准 JSON 行(JSON Lines)。
形如:{"timestamp": "...", "level": "INFO", "message": "..."}
不是 JSON,或不含基本字段,则返回 None(不是我的格式)。
"""
line = line.strip()
if not (line.startswith("{") and line.endswith("}")):
return None
try:
obj = json.loads(line)
except json.JSONDecodeError:
return None
if not isinstance(obj, dict):
return None
# 至少要有一个基本字段,才认为是“合法的 JSON 日志”
if not any(k in obj for k in ("timestamp", "level", "message")):
return None
return obj
class LogParserEngine:
"""日志解析系统:持有一组解析器,并支持热加载注册新解析器。"""
def __init__(self) -> None:
self._parsers: List[Tuple[str, ParserFn]] = []
# -- 注册 / 查询 --------------------------------------------------------
def register(self, name: str, fn: ParserFn) -> None:
"""注册(或替换同名)解析器。新解析器优先级更高,放到列表末尾后再尝试。"""
# 若同名已存在则先移除,实现“热更新替换”
self._parsers = [(n, f) for (n, f) in self._parsers if n != name]
self._parsers.append((name, fn))
@property
def parser_names(self) -> List[str]:
return [n for n, _ in self._parsers]
# -- 解析 ---------------------------------------------------------------
def parse_line(self, line: str) -> Dict:
"""尝试用每个解析器解析一行;成功则在结果里标注 _parser。全部失败抛 ParseError。"""
for name, fn in self._parsers:
try:
result = fn(line)
except Exception:
# 某个解析器对这行报错,不代表别的不行,继续尝试
continue
if result:
return {"_parser": name, **result}
raise ParseError(line)
# -- 热加载:从 .py 文件加载 parse 函数 ----------------------------------
@staticmethod
def load_parser_from_file(path: str) -> ParserFn:
"""把一个 parsers/*.py 模块动态导入,取出其中的 parse 函数。"""
module_name = "genparser_" + os.path.splitext(os.path.basename(path))[0]
spec = importlib.util.spec_from_file_location(module_name, path)
if spec is None or spec.loader is None:
raise ImportError(f"无法加载模块:{path}")
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module) # 执行模块,定义 parse
fn = getattr(module, "parse", None)
if not callable(fn):
raise ImportError(f"{path} 中未找到可调用的 parse(line) 函数")
return fn
def load_persisted(self, parsers_dir: str) -> List[str]:
"""启动时把 parsers/ 目录下已持久化的解析器全部热加载注册(复用历史成果)。"""
loaded: List[str] = []
if not os.path.isdir(parsers_dir):
return loaded
for fname in sorted(os.listdir(parsers_dir)):
if not fname.endswith(".py") or fname.startswith("_"):
continue
path = os.path.join(parsers_dir, fname)
fn = self.load_parser_from_file(path)
name = os.path.splitext(fname)[0]
self.register(name, fn)
loaded.append(name)
return loaded
tester.py¶
"""
tester.py —— 自动测试(对生成的解析器做数据结构断言)
书中原方案:把生成的可视化代码放进虚拟浏览器渲染,再用 Vision LLM 检查图像。
本机没有 playwright/浏览器,因此**降级**为对解析函数做单元测试:
用一批样本日志喂给生成的 parse 函数,断言它能解析出预期的结构化字段。
这保证了“生成的代码确实能正确解析新格式”,是自愈闭环里真正的质量闸门。
"""
from __future__ import annotations
from typing import Callable, Dict, List, Optional
ParserFn = Callable[[str], Optional[Dict]]
def run_tests(
parse_fn: ParserFn,
samples: List[str],
required_keys: List[str],
) -> Dict:
"""对 parse_fn 跑一组断言,返回 {passed: bool, report: str, results: [...]}。
通过条件(对每一条样本都要满足):
1. parse_fn(line) 不抛异常;
2. 返回值是非空 dict;
3. required_keys 中的每个字段都存在,且值不为空(非 None、非空字符串)。
"""
lines: List[str] = []
results: List[Optional[Dict]] = []
all_passed = True
for i, sample in enumerate(samples, 1):
try:
out = parse_fn(sample)
except Exception as exc: # 生成的代码在样本上直接崩了
all_passed = False
results.append(None)
lines.append(f"[样本{i}] 解析抛出异常:{type(exc).__name__}: {exc}")
continue
if not isinstance(out, dict) or not out:
all_passed = False
results.append(out)
lines.append(f"[样本{i}] 未返回非空 dict,实际返回:{out!r}")
continue
missing = [k for k in required_keys if k not in out or out[k] in (None, "")]
if missing:
all_passed = False
lines.append(
f"[样本{i}] 缺少/为空的必需字段:{missing};实际解析出:{out}"
)
else:
lines.append(f"[样本{i}] 通过,解析出字段:{sorted(out.keys())}")
results.append(out)
report = "\n".join(lines)
return {"passed": all_passed, "report": report, "results": results}