1. 问题背景:被非结构化日志逼疯的下午

我们有一个内部数据管道,需要从混合了INFO/ERROR/WARN级别、且包含堆栈跟踪和嵌套JSON的原始日志中,抽取request_iduser_iderror_code三个核心字段。正则写了三版,遇到日志里出现"request_id": "null"或键名变形(如requestId)就彻底失效。我当时的想法是:“让LLM来做语义抽取吧”。

但第一版朴素Prompt(就一句“提取JSON字段”)上线后,生产环境准确率惨不忍睹:27%。问题不在于模型,而在于我的指令完全没有约束输出格式和边界条件。这篇文章就是记录我如何通过工程化的Prompt迭代,把这个数字拉起来的。

2. 环境与版本:固定变量是实验的前提

为了公平对比,整个实验锁定以下环境:

  • 模型:gpt-4o-mini-2024-07-18(温度设为0,关闭随机性)
  • API:openai>=1.35.0,Python 3.10
  • 测试集:从生产环境随机采样100条原始日志,人工标注正确字段作为Golden Set
  • 评估指标:字段级准确率(三个字段全部正确才算1条通过,部分正确不计分)
  • 成本计算:使用response.usage.prompt_tokenscompletion_tokens求和

所有请求都通过同一个函数发出,唯一变化的是SYSTEM_PROMPTUSER_PROMPT的内容。

3. 方案设计:从“一句话”到“结构化约束”

我设计了五个递进版本:

  • V1:零样本,直接提问。
  • V2:加入输出格式约束(JSON only)。
  • V3:在V2基础上加入“字段不存在时输出null”的边界条件。
  • V4:少样本示例(Few-shot),加入2个典型错误日志作为正例。
  • V5:自校正(Self-Correction),让模型先抽取,再根据原始日志校验一次。

核心实现代码如下,这是所有版本共用的调用函数:

# prompt_lab.py
import json
from openai import OpenAI

client = OpenAI(api_key="sk-...", base_url="https://api.openai.com/v1")

def extract_fields(log_text: str, system_prompt: str, user_prompt_template: str) -> tuple:
    user_prompt = user_prompt_template.format(log=log_text)
    resp = client.chat.completions.create(
        model="gpt-4o-mini-2024-07-18",
        temperature=0,
        messages=[
            {"role": "system", "content": system_prompt},
            {"role": "user", "content": user_prompt}
        ],
        response_format={"type": "json_object"}  # 强制JSON模式
    )
    content = resp.choices[0].message.content
    usage = resp.usage
    return content, usage.prompt_tokens + usage.completion_tokens

4. 核心实现:五版Prompt的完整迭代记录

V1:零样本(基线)

system_v1 = "你是一个日志解析助手。"
user_v1 = "请从以下日志中提取request_id, user_id, error_code,返回JSON:\n{log}"

结果:准确率27%。主要失败模式:模型把error_code写成了字符串描述(如"database connection timeout"),而我们的标准是数字码"50032"。输出键名也出现requestId这种变形。

V2:格式约束

system_v2 = "你是一个严格的JSON输出器。只输出JSON对象,不要有任何解释。"
user_v2 = "从日志中提取字段,输出格式为:{\"request_id\": string, \"user_id\": string, \"error_code\": string}。\n日志:{log}"

结果:准确率提升到44%。JSON语法错误消失,但字段值错误依旧——模型会把error_code从堆栈跟踪里挖出一个无关的HTTP状态码。

V3:边界条件显式化

system_v3 = "你是一个日志解析器。规则:1. 字段不存在时,值设为null。2. error_code必须是6位数字字符串。3. 禁止推测,只能从原文提取。"
user_v3 = "日志内容:{log}\n请提取字段。"

结果:准确率53%。这里有个有意思的发现:gpt-4o-mini禁止推测的理解很微妙——它确实不再编造不存在的字段,但会把"error_code": "unknown"当成合法值。

V4:少样本(Few-shot)

我在system prompt里塞了两个完整示例,包含一条错误日志和正确的输出。

system_v4 = """
你是一个日志解析器。参考以下示例:

示例1:
日志: "ERROR 2024-03-01 10:00:00 req_7f3a2b error_code=50032 stack: KeyError"
输出: {"request_id": "req_7f3a2b", "user_id": null, "error_code": "50032"}

示例2:
日志: "INFO 2024-03-01 10:00:01 user_91x requestId=abc123"
输出: {"request_id": "abc123", "user_id": "user_91x", "error_code": null}

现在处理下面日志:
{log}
"""

结果:准确率跃升至78%。Token消耗从V3的412涨到1842(示例占了大量token),成本上升4.5倍,但准确率提升25个百分点,这笔买卖划算。

V5:自校正(两轮调用)

第一轮用V3的prompt抽取,第二轮把原始日志+第一轮输出一起喂给模型,让它检查并修正。

def self_correct(log_text: str) -> tuple:
    first_pass, tokens1 = extract_fields(log_text, system_v3, user_v3)
    correction_prompt = f"""原始日志:{log_text}
第一轮抽取结果:{first_pass}
请检查抽取结果是否与原始日志一致。特别注意error_code是否是6位数字,request_id是否完整。修正后输出最终JSON。"""
    resp = client.chat.completions.create(
        model="gpt-4o-mini-2024-07-18",
        temperature=0,
        messages=[{"role": "user", "content": correction_prompt}],
        response_format={"type": "json_object"}
    )
    return resp.choices[0].message.content, tokens1 + (resp.usage.prompt_tokens + resp.usage.completion_tokens)

结果:准确率91%(剩余9%错误集中在日志中request_id被截断成req_7f3a2这种半截值,模型无法判断是原文如此还是抽取错误)。Token消耗1062(比V4低,因为少样本示例被去掉了,多了第二轮校验的输入)。

5. 踩坑与优化:三个让我血压升高的细节

坑一:response_format不是银弹。强制JSON模式只保证输出是合法JSON,不保证键名正确。V2就吃了这个亏——模型输出了{"RequestId": "..."},key大小写完全不对。

坑二:少样本示例必须覆盖“边界情况”。V4最初我只放了一个正常日志示例,准确率只有65%。后来加入了一个user_id缺失的示例,准确率直接跳到78%。模型的少样本学习非常依赖样例的分布代表性。

坑三:自校正的Prompt不能太宽松。V5第一版我写的是“请检查并修正”,结果模型把本来正确的error_code: null改成了error_code: "null"(字符串)。加上了“除非字段值明显与原文矛盾,否则不要改动”这句话后,准确率才从85%升到91%。

6. 效果数据:Token成本与准确率的权衡

版本 准确率 平均Token消耗 单条成本(USD) 备注
V1 27% 156 $0.00003 基线
V2 44% 201 $0.00004 格式约束
V3 53% 412 $0.00008 边界条件
V4 78% 1842 $0.00031 少样本
V5 91% 1062 $0.00021 自校正

最终选择:V5上线。理由:虽然比V3贵2.6倍,但准确率从53%到91%意味着下游人工修复工作减少了80%。对于每天处理20万条日志的业务,多花的成本远低于人工成本。

7. 总结与经验

Prompt Engineering不是玄学,是实验科学。我的三点核心结论:

  1. 每加一个约束,都要跑一遍回归测试。V3加了“禁止推测”反而导致模型把合法值也过滤掉了,这种副作用只有通过数据才能发现。
  2. Token消耗要算总账。V4少样本准确率高,但token数量是V5的两倍。自校正的两轮调用看似浪费,实际上比塞一堆示例更省token。
  3. 模型版本更新后必须重新跑基准。我中途试过gpt-3.5-turbo,同样V5 prompt准确率只有62%,换回gpt-4o-mini才稳定。

最后留个思考题:如果你的日志里出现Base64编码的字段,你会怎么设计Prompt?欢迎评论区交流。