把 GPT-6.1 Terra 的返回直接 INSERT 进业务表,问题不在于模型会出错,而在于出错之后你没有位置去拦、没有记录去查。可行的做法是在调用和写库之间固定加一层:先做字段校验和类型转换,把原始输出与引用片段另存一份,低置信或空字段不静默通过而是进复核队列,最后用异常样本回放确认这层校验真的拦得住。模型名字换成哪个都一样,接入侧按“不可信外部输入”处理即可。
模型输出入库前建议固定三件事:结构校验、原始留存、复核分流。结构校验负责把类型错误和缺失字段挡在写库之前,失败时返回结构化错误而不是抛异常;原始输出表按 request_id 关联,排障时能回溯到具体一次调用和对应提示词版本;置信度低或必填为空的字段进复核队列,由人决定是否回写。留存周期和置信阈值需要结合业务合规与字段重要度确认,不能一套参数套所有表。
入库前加字段校验和类型转换
校验层的位置在“解析出结构化对象之后、写库之前”,不要在写库语句里用数据库类型去做隐式转换,那样失败的记录会被静默丢弃或者截断。下面是一个不依赖特定 SDK 的通用骨架,字段规则用配置描述,方便按表替换。
# schema_guard.py —— 放在模型调用与写库之间
from decimal import Decimal, InvalidOperation
NULL_TOKENS = {'', 'null', 'none', 'n/a', '-', '未知', '未提供'}
def to_number(raw):
if raw is None:
return None
s = str(raw).strip().replace(',', '')
if s.lower() in NULL_TOKENS:
return None
try:
return Decimal(s)
except InvalidOperation:
return 'INVALID' # 用一个哨兵值区分“空”和“类型错”
def validate(record, spec):
data, errors = {}, []
for field, rule in spec.items():
val = record.get(field)
if rule['type'] == 'number':
v = to_number(val)
if v == 'INVALID':
errors.append({'field': field, 'code': 'type_mismatch', 'raw': val})
continue
if v is None and rule.get('required'):
errors.append({'field': field, 'code': 'missing_required', 'raw': val})
continue
data[field] = v
elif rule['type'] == 'string':
s = '' if val is None else str(val).strip()
if s.lower() in NULL_TOKENS:
s = None
if s is not None and len(s) > rule.get('max_len', 2000):
errors.append({'field': field, 'code': 'too_long', 'raw_len': len(s)})
continue
if s is None and rule.get('required'):
errors.append({'field': field, 'code': 'missing_required', 'raw': val})
continue
data[field] = s
return {'ok': not errors, 'data': data if not errors else None, 'errors': errors}
转换规则建议集中在三处:字符串转数值先去千分位逗号和空白,转换失败不要返回 0 或默认值,而是记 type_mismatch;空值归一按业务语义统一成 NULL,注意“0”和“未提供”要分开,金额为 0 与没给金额是两件事;字符串统一去首尾空白并设长度上限,超长不截断入库,先判失败。校验失败时返回 {ok, data, errors} 结构,errors 里带字段名、错误码和原始值,调用方据此决定是重试、进复核还是丢弃,而不是靠捕获异常来判断类型。
把模型原始输出和引用片段单独存
校验通过的数据写业务表,原始返回写一张只追加的旁路表,两者用 request_id 关联。旁路表不要放业务逻辑,也不要被 UPDATE,它的作用只有一个:出了问题能还原到某一次调用。
CREATE TABLE llm_raw_output (
id BIGINT PRIMARY KEY,
request_id VARCHAR(64) NOT NULL, -- 调用侧生成并透传到写库端
prompt_version VARCHAR(32) NOT NULL, -- 提示词模板版本号,不是提示词全文
model_name VARCHAR(64) NOT NULL,
raw_output TEXT NOT NULL, -- 模型原始返回,不做裁剪
citations JSON NULL, -- 引用片段/检索命中的原文
schema_version VARCHAR(32) NOT NULL, -- 校验规则版本,规则改过要能对上
validate_status VARCHAR(16) NOT NULL, -- pass / fail / review
created_at TIMESTAMP NOT NULL
);
CREATE INDEX idx_raw_request ON llm_raw_output (request_id);
留存周期看两件事:排障窗口和合规要求。通常排障只用到最近几天到几周的原始输出,可以先按 7 到 30 天热存,到期后压缩归档或只保留哈希与失败记录;涉及个人信息或客户内容的字段,需要在写入前脱敏或按敏感级别单独存储。具体天数要和日志保留策略、审计周期对齐,这块没有通用答案,需要结合环境确认。提示词版本号建议跟模板一起进版本库,改提示词时同步升版本,否则回查时无法判断是模型漂移还是提示词变更。
低置信或空字段走人工复核队列
不确定的数据不要靠“看起来还行”放过去,给一个明确的进队条件。常见判定是:字段置信度低于配置阈值、必填字段归一后仍为空、类型转换失败但字段本身重要、文本超长、取值不在枚举白名单内、引用片段与结论明显不匹配。阈值每个字段可以不同,金额和日期这类字段阈值设高一些。
CREATE TABLE review_queue (
id BIGINT PRIMARY KEY,
request_id VARCHAR(64) NOT NULL,
raw_id BIGINT NOT NULL,
field_name VARCHAR(64) NOT NULL,
reason_code VARCHAR(32) NOT NULL, -- low_confidence / missing_required / type_mismatch / too_long / out_of_enum
candidate_value TEXT NULL,
status VARCHAR(16) NOT NULL DEFAULT 'pending', -- pending / approved / rejected / edited
reviewer VARCHAR(64) NULL,
decided_value TEXT NULL,
decided_at TIMESTAMP NULL
);
回写方式建议是“复核结果另存,再推进业务表”:approved 用原候选值回写,edited 用 decided_value 回写,rejected 保持业务表为空并标记数据不完整,任何情况下都不直接改 llm_raw_output。回写时按 request_id + field_name 关联,保留复核人和时间,后续追溯时能看出这条数据是人改过的还是模型直接给的。
用异常样本回放验证校验层
校验层写完后不能只拿几条正常样本跑通就上线。准备一批故意做坏的样本,覆盖缺字段、类型错、超长文本、枚举外取值、空字符串与字符串形式的“null”,把它们灌进校验函数,比对实际结果和预期结果。
cases = [
{'name': '缺必填', 'input': {'amount': None}, 'expect': 'fail'},
{'name': '数字带千分位', 'input': {'amount': '1,200.50'}, 'expect': 'pass'},
{'name': '数字是文本', 'input': {'amount': '约一千'}, 'expect': 'fail'},
{'name': '空值写成 null 字符串', 'input': {'note': 'null'}, 'expect': 'pass_null'},
{'name': '超长文本', 'input': {'note': 'x' * 5000}, 'expect': 'fail'},
]
回放时统计三个数:应拦且被拦住的拦截数、不该拦却被拦下的误拦数、该拦却放过去的逃逸数。拦截数用来确认规则确实生效,误拦数用来发现阈值或空值归一规则定得过紧,逃逸数是重点,每发现一条就补一条样本并修规则,样本集跟着规则版本一起存。验证方式可以放在 CI 里跑,也可以做成离线脚本,规则改动后重跑同一批样本,比较逃逸数有没有回升。这批样本是合成的,只能说明校验层在已知坏输入上的行为,不能代替真实流量的观察,上线后仍需抽查失败记录和复核队列的积压情况。