1. 问题背景:被非结构化日志逼疯的下午
我们有一个内部数据管道,需要从混合了INFO/ERROR/WARN级别、且包含堆栈跟踪和嵌套JSON的原始日志中,抽取request_id、user_id、error_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_tokens与completion_tokens求和
所有请求都通过同一个函数发出,唯一变化的是SYSTEM_PROMPT和USER_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不是玄学,是实验科学。我的三点核心结论:
- 每加一个约束,都要跑一遍回归测试。V3加了“禁止推测”反而导致模型把合法值也过滤掉了,这种副作用只有通过数据才能发现。
- Token消耗要算总账。V4少样本准确率高,但token数量是V5的两倍。自校正的两轮调用看似浪费,实际上比塞一堆示例更省token。
- 模型版本更新后必须重新跑基准。我中途试过
gpt-3.5-turbo,同样V5 prompt准确率只有62%,换回gpt-4o-mini才稳定。
最后留个思考题:如果你的日志里出现Base64编码的字段,你会怎么设计Prompt?欢迎评论区交流。