过去一年,我们把大模型接进一款跑了多年的某电商接待机器人,前端入口是微信客服会话。接完之后线上出的事故,复盘下来没有一起的根因在模型里:模型本身没有变笨,价格没变,上游也没换过几次。出事的全是包在它外面的那一层——喂给它的教材、下一轮带进去的上下文、往外传输的流式协议、以及放它执行动作之前的那道闸。这一层有个英文名字,Harness,直译是"挽具":让一匹马愿意拉车的所有马具、缰绳和车辕。我给它记的账是:系统风险里模型自身占两成上下,Harness 占八成——这个数不是什么统计结论,是把下面几起事故按所在层归一次类之后的结果。每修好一起,系统行为的变化量都超过我们调过的任何一次模型参数。所以这篇不聊提示词怎么写得更巧,聊的是真正拦住事故的那几层工程:喂什么、怎么传、怎么兜底。

一、先把"模型外面那一层"的边界画出来

大模型一旦进真实产品,系统就变成一条链:教材语料与知识库 → 上下文组装 → 模型推理 → 流式输出 → 客户端渲染,外加执行动作之前的意图闸、事实闸、幂等与缓存。模型只占链条中段那一格,而且那一格有两个好性质:它无状态,不会记得什么坏事;它可替换,上游永远有备选。好消息是能力退化容易回滚,坏消息是——事故基本不发生在它身上,发生在你喂它什么、它说出去的话怎么传、传出去之前有没有人把关。

把这几层摊开,对应到我们这一年的事故清单上:

事故现象一句话所在层根因一句话修复动作
教材放大器回复带表情比例 46%,提示词压不住喂什么·语料60 条样例里 50 条自带表情,模型照样例学样例表情清零 + 按会话比例放行
上下文污染复读同一段话反复回,越滚越长喂什么·上下文检索命中的答案被当成"客户说的话"写回上下文只存双方原始消息
意图闸误伤71 秒内自动发了 4 次报价单怎么兜底·闸关键词匹配命中对面 AI 话术的字面关键词判意图整体废弃,规则只守事实闸
流式碎片化打字机被打碎,客户端每秒重绘十几次怎么传·协议上游不合规范的小帧被原样透传网关重建 chunk + 120ms 微窗口合并
改语料不生效教材洗完,线上行为纹丝不动喂什么·缓存版本元数据没 bump,缓存键没变改语料必须 bump 版本,版本由指纹生成

五行,一年,全中。“所在层"这一列就是 Harness 的分界:喂什么(语料、上下文、缓存),怎么传(流式协议、边界),怎么兜底(意图闸、事实闸、幂等)。模型层不在这张表里——不是说它没出过格式不遵守、偶尔瞎编这类毛病,是那些最后都在边界层被兜掉了,值得单独立案的事故一件也没有。下面按"现象→排查弯路→真根因→修法→教训”,把前四起挨个复盘,第五起短一些,但它是前一起的续集。

二、事故一:教材放大器——提示词压不住的,示范在压你

现象

RAG 知识问答上线一段时间后,运营做话术回查,发现回复带表情符号成了一个显著问题:抽样统计,带表情的回复比例到了 46%。电商接待的场景里,客户正为退款催得冒火,收到一个眨眼的表情,投诉概率是要涨的。注意这不是模型答错了内容,是答对了内容、用错了语气——内容层面的抽检一切正常,恰恰说明这类问题不在"答对答错"的监控视野里。

排查弯路

第一反应当然是提示词。加"不要使用表情符号",不行,比例降了一点又回去;加得更重,“禁止输出任何表情符号,包括文字版表情”,还不行;再往后把常见表情列成负面清单逐条点名,来回十几版提示词,比例卡在 30% 以上下不来,有时不降反升。期间也动过把温度压到接近零、换上游模型的念头,都被另一件事按住了:换模型一次要跑一轮全量回归,成本摆在那,而"46%"这个数字实在不像采样波动能解释的量级。有同事在准备动手换模型之前提了一句:要不再看看发给模型的消息里,除了指令还有什么?

真根因

那一轮请求里,除了系统提示词,还带着与知识库配套使用的对话样例——也就是教材。60 条样例,数了一下,50 条的回复里自带表情。于是模型同时收到两种信号:指令说"不许用",五十条示范说"用了很正常"。指令管显式规则,示范管行为倾向,两者冲突时,倾向赢。46% 差不多就是 50/60 这个比例,被提示词的那点抑制力往下拽了拽之后的结果。提示词不是没用,是它的杠杆长度压不过教材的示范效应——你怎么改措辞,都改不了"多数样例都这么回"这个事实。

修法

两步,都不在提示词里做。第一步,洗教材:样例里的表情全部清零,业务上确实需要某种语气的个别样例除外——清点下来几乎没有一条真的需要。第二步,把"允许不允许带表情"从提示词里的禁令,改成一个按会话维度裁决的开关:以近期窗口统计,大约三成会话允许带表情,其余会话一律纯文本,这个开关在网关侧实现,进请求之前就把指令和教材一起摆平。修完之后监控里表情比例回落到可以接受的区间,并且"可接受的区间"本身成了一个可配置项,而不是靠提示词措辞去碰运气。

教训

语料质量闸的优先级高于提示词。模型行为倾向出问题,第一个该看的不是自己写了什么指令,而是它正在照着什么示范学。这个顺序定下来,能省掉十几轮提示词往返。后来我们把这条写进了排查手册的第一行:先查教材,再查提示词,最后才轮到模型。

三、事故二:上下文污染——自己的回答被当成客户的话,再喂回去

现象

部分会话偶发复读:客户问了一件事,机器人回了一段;下一轮,又把上一段几乎原样回一遍。有时是两句相邻回复高度重复,有时越滚越长,像卡住了,直到人工介入才停。

排查弯路

这个现象有个教科书式的名词:退化重复。于是先按教科书排查——调重复惩罚,考虑升温,怀疑采样把模型推进了循环。又怀疑是检索在捣乱,某一条知识被反复命中所以反复输出,拉出检索日志核对,命中条目其实是有变化的,对不上。绕了几天,真正转向是从一个具体动作开始的:把某一出问题会话实际发给模型的 messages 原样打出来看。一眼看到一条"客户消息",内容是机器人自己上一轮的完整回复。

真根因

上下文组装环节里,知识库命中的答案被以"客户说的话"的身份写回了会话上下文。模型下一轮看到的是一条"客户提出的问题",而这条"问题"恰是自己的上一段回答——它的合理行为就是再答一遍,或者就着这段话再确认一遍。污染条目不会自己消失,它留在上下文里,每一轮都被重新带进请求,像滚雪球;复读要等到窗口把它顶出去才自然停止。模型没有病,是我们给它喂了一份假账。

修法

上下文写回加一道守卫,只允许原始消息进门:

// context-guard.js —— 上下文只允许两种身份进门
// role:   'customer' | 'agent'   —— 谁说的
// source: 'raw' | 'generated' | 'retrieved' | 'fallback' —— 从哪来的
function appendTurn(ctx, msg) {
  if (msg.source !== 'raw') {
    // 模型回复、检索摘要、兜底话术,一律不许伪装成任何一个角色进入上下文。
    // "上一轮我说过什么"确实要带给模型:只允许以 role='agent' 的原始发送记录进入,
    // 让模型知道这是"我说的",而不是客户说的。
    metrics.inc('context_append_rejected', { source: msg.source });
    return ctx;
  }
  if (msg.role !== 'customer' && msg.role !== 'agent') return ctx;
  return [...ctx, { role: msg.role, content: msg.text, ts: msg.ts }];
}

规矩两条:上下文里只存对话双方的原始消息——客户说的记成客户,我们发出去的记成我们;任何生成内容和检索内容不得以客户身份进入上下文。这里有个取舍值得说清楚:"上一轮机器人说过什么"本来就需要让模型知道,否则多轮接不上。但正确做法是让它以"agent 原始发送记录"的身份进上下文,而不是伪造一条客户消息。复读的成因有第二条腿——模型分不清喂进去的内容是不是自己说的——但这条腿不该靠模型去分辨,该在写回那一刻就不让上下文变脏。

教训

上下文是数据管道,不是日志桶。每条消息的出处(谁说的、从哪来)必须是写回时的必查字段。出处错误比噪声危险:噪声只让模型这一轮说错话,出处错误会让模型把假话当事实继续推理,而且每一轮都带着它。

四、事故三:关键词意图闸——对面一句客气话,让我发了四次报价单

现象

产品里有一条自动流程:当判断客户在要报价单时,接待机器人直接发送报价文件。某天这条流程失控,对同一个会话在 71 秒内自动发了 4 次报价单。客户一头雾水,发来一句"怎么发这么多遍"。

排查弯路

第一反应是节流没生效,翻网关配置,节流参数正常;第二反应是模型异常触发了发送调用,调出四次触发的来源记录,发现四次全部由一个确定性的关键词闸触发,跟模型没有半点关系。这次事故里模型压根不在场。

真根因

“客户是否要报价单"这个判断,当年是用关键词匹配写的:消息里出现"报价单”“发报价"之类的字样就判定成立,放行发送。问题在于那个会话的对面,接的也是一家 AI 客服。对面的话术里有一句"您发报价单过来我帮您看看”——“发报价单"三个字,字面命中。两边都是模型,一边的客套话成了另一边的执行指令。关键词闸不知道这句话是谁说的,不知道语义上是"我要一份"还是"你把你的发我一份”,它只看得见字面命中;命中之后,发送动作又没有会话级幂等,于是闸每被碰一次,报价单就发一次,71 秒四次。

修法

当天的拍板结论很硬:关键词判意图,整体废弃。意图判断交给模型——它看得到整句话、说话的人、语气和上下文,分得清"给我来一份报价单"和"您发报价单过来我帮您看看"的差别,关键词规则永远写不出这个判别式。但确定性规则不是全拆,而是换岗,只守事实闸:

维度事实闸(保留,确定性代码)意图闸(废弃关键词,交给模型)
判断对象状态:报价文件是否存在、该客户是否可见该价目、本会话是否已发送过语义:客户是不是在要报价单
输入结构化数据,取值可枚举自然语言,整句加上下文
失效模式数据缺失,误判可审计、可回放字面串误伤,语言面穷举不完
兜底手段发送前校验 + 会话级幂等键动作侧节流、当日发送上限、异常告警

重建后的流程是一串与运算:模型判意图成立,且文件存在,且该客户可见,且本会话未发送过,四个条件全真才执行发送;发送动作带会话级幂等键,那 71 秒里的第四次会被动作侧直接拦掉。闸还在,而且比原来更硬,只是它从此不读文本猜心思,只读状态对事实。

教训

确定性规则只能守事实闸,不能守意图闸。意图是语言,事实是状态;拿规则在语言面上匹配意图,早晚被语言淹——尤其对面也接了模型之后,AI 话术里那些高频字面串,恰好是关键词表最躲不开的。还有一条附赠的:节流和幂等必须放在动作侧。放在触发侧,等于要求所有可能的触发者自觉,而触发者里现在多了一个会客套话的模型。

五、事故四:流式碎片化——上游打碎的字,不该到客户端再补

现象

接待客户端用流式方式展示模型的思考过程与回复,像打字机。某段时间开始,打字机变得磕磕绊绊:思考内容一行一个字往外蹦,客户端每秒重绘十几次,界面视觉上是碎的。

排查弯路

先在客户端动手:给重绘加节流、改脏矩形、上虚拟滚动,做完卡顿缓解了一点,但"碎"没消失——因为文本本身进来就是碎的,渲染层只是收到了什么画什么。又怀疑上游模型吐字速度变了,抓了对比,结论模棱两可。真正的转折是有人把上游原始响应帧直接抓了下来:网关在透传,而透传出来的帧本身就不合规范——一行一个字的小帧、部分帧缺协议必带字段、finish_reason 这个字段有的上游传的是空字符串(按协议应该是 null 或者枚举值,空串是第三种东西,两边解释可以完全不同)。客户端收到一堆不合协议的伪帧,只能照单全收。

真根因

透传。网关把"传输"做成了"中继",上游发什么帧就原样转什么帧,把上游的帧行为当成了自己对外契约的一部分。上游是第三方网关,它的帧规范在我方控制之外,而它显然没有严格守流式协议。协议不合规范这件事,一旦发生透传,就会免费扩散到每一个下游模块。

修法

不透传。网关不再把上游帧原样下发,而是把它解析成增量文本,按我方与客户端约定好的流式协议重新建帧,加微窗口合并:

# 网关侧流式重建(伪码):吸收上游碎帧,吐出自家合规流
buffer       = ""     # 待下发的增量文本
window_start = None   # 微窗口起点
seq          = 0      # 自有序号,与上游帧号彻底脱钩

on 上游增量到达(delta):                # 上游帧再碎,止于此处
    if window_start is None: window_start = now()
    buffer += delta.text               # 只取增量文本,不碰上游帧结构
    if now() - window_start >= 120ms or buffer 到达句子边界:
        下发合规chunk(seq += 1, content = buffer, finish = null)
        buffer = ""; window_start = None

on 上游连接结束:                        # 不信上游的 finish_reason 字段
    # 结束信号以自己的语义为准:收到显式终止标记或读超时
    等微窗口到期,把剩余 buffer 作为最后一个正文 chunk 下发
    下发收尾 chunk(finish = "stop")    # 枚举值一律由网关生成

窗口取 120ms,是在"重绘频率降到个位数每秒"和"体感延迟不可接受"之间量出来的一个折中:上游再疯,到我这就变成最多每 120ms 一帧的稳流;客户端只面对自家协议,不面对上游的脾气。finish_reason 由网关统一生成,不再有空串外泄。效果是问题从结构上消灭了——不是给每种坏帧写一个补丁,而是坏帧根本进不了下游视野。

教训

边界层的协议要在我方收敛,而不是转发。上游不合规范这件事,该在我的边界上被结构性地终结;在客户端和每个下游模块里逐帧打防御补丁,是同一个学费交 N 遍。对上游协议的防御是网关的职责,不是 UI 的职责。这条不限于流式:任何把"字段语义由上游决定"当省事设计的透传,都在埋伏笔。

六、续集:改语料不是存个文件,是一次发布

事故一洗完教材,我们紧接着撞了一个小但经典的坑:表情清零,发布,监控里表情比例纹丝不动,线上行为像什么都没发生。当时第一反应是"洗教材没用,示范效应没那么强",差点把已经做对的修法推翻。

真根因在模型和语料之外:上下文组装侧有缓存,缓存键里带语料的版本标识。那次改动只替换了内容文件,版本元数据没 bump——键没变,缓存永不失效,线上读到的还是旧教材。"改了没生效"看起来像模型问题,其实是缓存问题;而它之所以能骗过我们,是因为现象和"提示词压不住教材"太像了。

修法是一条死规矩:改语料必须带版本变更,且版本标识由内容指纹自动生成,不靠人记得手改。语料的进出走发布流程——审、版本、灰度、观察,和代码一个待遇。教训和事故一同层,合起来是一句话:教材语料是系统行为的依赖项,凡是依赖项,改动就是发布,发布就得让所有缓存和副本知道。

七、Harness 检查清单

全文压成十条,按事故代价从大到小排。每条都对应上面某一次交过的学费,没有一条是凭审美加的。

  1. 模型行为倾向出问题(语气、冗长度、格式偏好),先查教材语料,再查提示词,最后才轮到怀疑模型。
  2. 提示词指令与教材示范不许对着干;对着干时,示范赢。
  3. 上下文写回必须过守卫:只允许双方原始消息进门,生成、检索、兜底内容一律不得以客户身份进上下文。
  4. 意图判断交给模型,确定性规则只守事实闸——存在性、可见性、是否已执行过。
  5. 节流与幂等放在动作侧,不依赖触发侧自觉;触发者里可能站着一个会客套话的模型。
  6. 上游流式帧不透传:解析成增量文本后按自家协议重建 chunk,用微窗口合并摊平碎帧。
  7. 协议级枚举字段(如结束原因)由我方边界生成,上游的空串、缺字段不许扩散到下游。
  8. 语料与知识变更必须 bump 版本,版本由内容指纹生成;缓存键里没版本,等于没有缓存失效机制。
  9. 监控要看行为比例(表情率、复读率、误触发率、重绘频率),光看错误率和延迟,这几起事故一起都不会报警。
  10. 每次事故复盘先定位所在层:喂什么、怎么传、怎么兜底。不在模型层的事故,不要用换模型去治。

八、几句收尾

两成和八成的账,不是贬低模型。恰恰相反,模型能力每往前一年,那两成还在继续变小;但 Harness 的八成不会跟着模型升级消失,它只会换个地方重新长出来——教材换一批,污染换个姿势;协议换一版上游,碎帧换个花样。马越壮,挽具越不能凑合,因为力气大了,挣脱之后跑得更远。

这一年四起事故加一个续集,教会我们一件事:别指望模型更自觉,要让它外面的那一层没有不自觉的空间。喂进去的每一条消息有出处,传出去的每一帧有归属,执行前的每一个动作有闸。做到这三件,模型就只是链条里那个可以随意替换、也很少背锅的零件——而"零件很少背锅"这个状态本身,就是 Harness 工程的全部意义。

Logo

DAMO开发者矩阵,由阿里巴巴达摩院和中国互联网协会联合发起,致力于探讨最前沿的技术趋势与应用成果,搭建高质量的交流与分享平台,推动技术创新与产业应用链接,围绕“人工智能与新型计算”构建开放共享的开发者生态。

更多推荐