AI 工程师为什么还是得补软件工程:接口契约、回放句柄和幂等键比 Prompt 更救命
有次上线一个看起来很轻的生成接口,前端只是多加了一个“重新生成”按钮,工作流层为了稳妥又补了自动重试,结果当天晚上数据库里就出现了三份互相打架的结果。产品同学以为模型突然变飘,工程同学第一反应去翻 Prompt,最后真正把问题钉住的,却是一串再普通不过的事实:入口没有幂等键,调用链没有统一 traceId,谁也说不清那三次请求到底是不是同一件事。
这类场面我后来见得越来越多。Demo 阶段,大家最在意的是模型会不会答得惊艳;一旦接进站点、审核流、批处理任务和权限体系,先出问题的往往不是回答本身,而是请求边界、副作用控制和排查能力。也就是从那个阶段开始,我对“AI 工程”这四个字的理解变得很朴素:模型负责推理,软件工程负责托底。没有后者,前者很难活过几轮迭代。
先把入口契约钉住,别让调用方自由发挥
很多 AI 功能最早都是从一个很宽松的函数起步的:
async function generateSummary(input: any) {
return callModel(input)
}
早期这样写确实快。问题是它默认了一件事: 调用方总会自觉地把场景、权限、上下文和副作用边界准备好。真实项目里这件事基本不会发生。前端会多塞一个临时字段,BFF 会偷偷补默认值,批处理任务会直接绕过页面那层校验。过一阵子你再回头看,同一个能力其实已经长成了三种入口、五种输入形状。
所以我现在更愿意从一份略显啰嗦的请求对象开始,让系统先回答清楚“这次调用到底是什么”:
type SummaryRequest = {
requestId: string;
idempotencyKey: string;
scene: 'chat' | 'review' | 'batch';
actorId: string;
locale: 'zh-CN' | 'en-US';
userInput: string;
inputSnapshotId: string;
attachments: Array<{kind: 'text' | 'image'; uri: string}>;
promptVersion: string;
traceId: string;
allowToolCalls: boolean;
toolPolicyId?: string;
}
这里最重要的不是“类型写得完整”,而是每个字段都在替未来的排查省时间。scene 决定编排分支,inputSnapshotId 让输入可回放,idempotencyKey 约束重试语义,toolPolicyId 则明确说明这次请求有没有资格触发外部动作。接口契约的价值从来不在代码审美上,而在于不同入口终于开始说同一种语言。
把编排、模型和副作用拆开,测试才不会沦为祈祷
另一个常见误区,是把“AI 不稳定”当成不做拆分的理由。结果一个函数里同时负责:
- 输入校验
- Prompt 组装
- 模型路由
- 工具调用
- 输出解析
- 数据落库
- 审计日志
这样的函数不是不能测,而是只剩下那种一碰就碎的“大集成测试”。用例一失败,你根本不知道是模板变了、供应商慢了、工具挂了,还是数据库写入顺序出了问题。
我后来把习惯改成了更笨一点的拆法:验证层只关心输入是否合法,编排层只决定调用顺序,模型适配层只处理与供应商的协议差异,副作用层单独负责写库、发事件和审计。这样做的收益很直接。你不再需要每次都把整条链路拉起来才能验证一个小改动,出问题时也能更快把锅缩到具体边界里。
没有回放句柄,事故复盘就只剩口供
很多团队以为“把请求和输出记下来”就算支持回放了,实际上远远不够。真正需要被保存的,不只是最终结果,而是那次执行对应的上下文版本。否则你能重放出来的往往只是“今天的系统怎么处理这份输入”,而不是“当时的系统为什么会给出那个结果”。
我现在更愿意在服务层保留一份轻量但完整的回放句柄:
type ReplayHandle = {
replayId: string;
requestId: string;
promptVersion: string;
modelRoute: string;
variablesSchemaVersion: string;
toolSnapshotIds: string[];
inputSnapshotId: string;
outputSnapshotId: string;
policyVersion?: string;
createdAt: string;
}
这类句柄最值钱的地方,不在“事后研究”,而在事故现场能立刻止损。有人说标题突然变宽了,或者审核结论忽然抖了,你不用先听三个人凭印象描述当时系统长什么样,直接拿 replayId 和 inputSnapshotId 把旧路径拉出来看。很多争论到这一步会立刻降温,因为讨论从印象变成了证据。
幂等不是支付系统专利,生成链路一样会写乱状态
生成类接口最容易让人掉以轻心。大家会觉得“顶多多生成一次文本”,不像支付那样严重。可一旦链路里带上写库、回写状态、通知下游、触发工具,重复执行马上就会变成真实事故。用户多点一次按钮、任务超时后自动重试一次、人工又手动补跑一次,最后不是多出一段文案,而是三条互相冲突的记录。
所以我现在会把幂等键绑定在“业务动作”上,而不是只绑定在模型调用上。比如“为这篇内容生成第一版摘要”是一个动作,“重新生成但覆盖原结果”是另一个动作,“只预览不落库”又是第三个动作。动作语义不同,幂等策略就应该不同。只要这层没想清楚,后面所有重试都可能在帮你制造脏数据。
AI 工程回到软件工程,并不是倒退
我现在已经不太把这件事理解成“AI 发展到最后又回到老路”。更准确的说法是,模型把系统能力往前推了一大步,软件工程负责给这一步加护栏。接口契约限制入口随意漂移,回放句柄把事故从口供变成证据,幂等控制把概率性失败挡在可恢复范围内,拆分后的模块边界则让测试终于有地方发力。
如果今天再让我总结一次 AI 工程里最该补的基本功,我大概不会先说 Prompt 技巧,也不会先说模型榜单。我会先看这条链路有没有明确请求契约,有没有回放句柄,有没有副作用幂等,有没有把编排和副作用拆开。因为这些东西决定的不是 Demo 好不好看,而是系统出了事以后,到底还能不能被人接住。
