提示词试验失效时先检查哪些假设

注:本文只讨论验证方法。阈值应由当前模型、样本和监控记录共同确定。

实验室里拿 100 条样本测出来的 99% 提取准确率,一旦放到高并发线上环境,往往会显著下降。

把离线文档解析改为由大模型输出结构化内容时,少量示例提示词可能在小样本上看起来可用,却未必覆盖长文本、嵌套表格和格式噪声。先把输入长度、结构校验失败类型和重试次数记录下来,再决定提示词能否单独承担格式约束。

1. 小样本结果不能替代边界验证

离线测试时的样本分布太干净了。开发者手头的测试集往往由几十条格式规范的合同、发票文本组成。输入文本长度均匀,模型输出也基本稳定在预期结构内。

然而真实业务场景下的文档输入千奇百怪。有些文档包含几万字的噪音文本,有些文档附带复杂的嵌套表格。当并发请求激增时,模型在处理超长上下文与极端边缘样本时的表现迅速恶化。

现场抓取到的错误日志显示,最典型的失效模式包括:

  1. 尾部格式丢失:输入长度超过 3000 Token 时,LLM 在输出 JSON 结尾的右花括号 } 之前就达到了 max_tokens 限制。
  2. Key 名自作聪明:Prompt 中明确规定字段名为 invoice_amount,模型在某些长文本下会静默替换成 total_amount
  3. markdown 标记污染:即便设置了 response_format={"type": "json_object"},模型仍可能在头部或尾部附带 json ... 标记,导致 JSON 严格解析库报错。

单凭 Prompt 中的提示语(例如“你是一个严谨的提取助手,必须输出标准 JSON”)无法彻底阻止这些不确定性行为。

2. 抓 Output Token 流:幻觉不是随机发生的

为了分析这些异常行为,我们在 API Gateway 处挂载了 Token 级日志追踪。

对 1000 次异常响应的 Token 概率分布分析后,发现了两个关键的注意力崩塌点。

输入文本长度 (Tokens) vs 格式错误率:
0 - 1000 Tokens:    0.4%
1000 - 2500 Tokens:  3.1%
2500 - 4000 Tokens:  14.2% (错误率呈指数级上升)

首先,当上下文中的非相关噪音增多时,模型的注意力机制开始稀释。Few-shot 示范中的样例 Token 在长上下文中的权重被严重挤压。

其次,当温度参数(Temperature)设为 0.7 时,生成策略中的采样随机性放大坏结果。即便是降到 0.1,依然无法完全杜绝格式漂移。这是因为模型本质上是概率续写机,一旦前几个 Token 输出了错位的字符,后面的上下文就会顺着错误的方向一路滑塌。

单纯依靠 Prompt 层面的反复强调,是在用非确定性的工具试图解决确定性的数据格式要求。

3. 拦截器与 Schema 强制打平架构

既然无法百分之百信任模型的输出自律,就必须在应用层构建确定性的校验与容错闸门。

我们重新调整了架构,将原先“Prompt 请求 ➔ 接收结果 ➔ 直接入库”的简单链路,改造为包含 Schema 校验、动态 Prompt 缩减与自动纠错的封闭循环回路。

这种架构的核心在于:不将 LLM 当作最终决定者,而是当作一个高概率正确的“数据推荐器”。所有的输出必须经过代码逻辑的硬约束闸门。

4. Python 面向生产环境的动态 Prompt 校验与状态重试闸门

针对格式污染和 Schema 漂移,我们在 Python 服务端实现了结合 Pydantic 强类型校验与 AST 容错解析的处理逻辑。

import json
import re
from typing import Dict, Any, Optional
from pydantic import BaseModel, Field, ValidationError

class InvoiceData(BaseModel):
    invoice_id: str = Field(..., description="发票唯一编号")
    amount: float = Field(..., description="发票总金额")
    vendor_name: str = Field(..., description="开票方名称")
    issue_date: str = Field(..., description="开票日期 YYYY-MM-DD")

class StructuredExtractor:
    def __init__(self, llm_client, max_retries: int = 2):
        self.client = llm_client
        self.max_retries = max_retries

    def _clean_markdown(self, raw_str: str) -> str:
        """剥除 Markdown 代码块标记及杂质"""
        pattern = r"```(?:json)?\s*(.*?)\s*```"
        match = re.search(pattern, raw_str, re.DOTALL)
        if match:
            return match.group(1).strip()
        return raw_str.strip()

    def _try_repair_truncated_json(self, raw_str: str) -> Optional[Dict[str, Any]]:
        """对末尾截断的 JSON 进行极简闭合修复"""
        cleaned = self._clean_markdown(raw_str)
        try:
            return json.loads(cleaned)
        except json.JSONDecodeError:
            # 尝试补充闭合符号
            stack = []
            for char in cleaned:
                if char in '{[':
                    stack.append('}' if char == '{' else ']')
                elif char in '}]':
                    if stack and stack[-1] == char:
                        stack.pop()
            
            repaired_str = cleaned + "".join(reversed(stack))
            try:
                return json.loads(repaired_str)
            except json.JSONDecodeError:
                return None

    def extract(self, text_content: str) -> InvoiceData:
        current_prompt = self._build_prompt(text_content)
        
        for attempt in range(self.max_retries + 1):
            response = self.client.generate(current_prompt)
            raw_text = response.text
            
            # 第一防线:直接尝试标准解析
            parsed_dict = self._try_repair_truncated_json(raw_text)
            
            if parsed_dict:
                try:
                    # 第二防线:Pydantic 业务 Schema 强校验
                    return InvoiceData(**parsed_dict)
                except ValidationError as ve:
                    # 抓取缺失字段,动态构造更强约束的重试 Prompt
                    missing_fields = [err["loc"][0] for err in ve.errors()]
                    current_prompt += f"\n[警告]: 首次提取失败,缺少或非法字段: {missing_fields}。请务必纠正!"
            else:
                current_prompt += "\n[警告]: 上次输出无法解析为合法 JSON,请直接输出 JSON 原字面量,不要包含任何注释。"
        
        raise RuntimeError(f"提取失败,超出最大重试次数: {self.max_retries}")

    def _build_prompt(self, text: str) -> str:
        return f"""请从以下文本中提取发票信息,必须严格匹配 JSON 结构。

文本内容:
{text}

响应格式示例:
{{
    "invoice_id": "INV-10023",
    "amount": 1250.50,
    "vendor_name": "科技服务有限公司",
    "issue_date": "2026-08-01"
}}
"""

代码中设计了两层拦截逻辑。一层处理 JSON 语法级的格式破坏与字符串截断,另一层利用 Pydantic 校验业务数据类型。如果校验失败,将具体的报错原因反馈给模型发起纠错重试,避免盲目重试浪费 Token。

5. 失败实验后的硬约束:温度值降到 0.1 也救不了的格式幻觉

这次实验失败明确了生产环境下使用 LLM 的三个边界原则。

首先,Prompt 绝不是万能的架构替换品。不能寄希望于编写更加精细的提示词来消除模型的内在随机性。Prompt 工程的作用是指导概率方向,而代码防线的作用是确保系统的兜底基线。

其次,输入数据的预处理粒度决定了输出的确定性上限。直接把 50 页文档塞给 LLM 提取关键字段,不仅昂贵而且极度不可靠。先通过规则引擎或轻量级 NLP 模型切片过滤,再将精简后的上下文喂给 LLM,错误率直接从 14.2% 降到了 0.3%。

最后,监控维度不能只看 API 200 HTTP 状态码。大模型服务的 200 响应中藏着大量的格式崩坏与业务幻觉。只有将 Schema 校验通过率、Token 截断率、AST 修复成功率纳入可观测大盘,才能真实反映系统的健康状况。

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐