AI客服系统架构复盘:LLM应用上线后,决定稳定性的不是模型也不是提示词——一个7×24电商客服机器人的大模型工程化实战
前阵子"从 Prompt Engineering 走向 Harness Engineering"这个话题上了热榜,核心论断一句话:模型不是产品。我认同这个判断,但想把它往前再压一层。做过 7×24 在线系统的人应该都有体感:LLM 应用上线之后,决定稳定性的那一层,既不是模型,也不是提示词,而是围绕模型的那圈支架(harness)——输出清洗、流式协议重建、上下文卫生、发送前闸门、失败兜底、成本意识。模型偶尔犯轴,提示词偶尔失配,但只要这圈支架做得够硬,客户那边就是感知不到的;反过来,支架薄一层,上游每一个不规范的习惯都会原样砸到真实客户脸上。
这篇是一台 7×24 在线的电商客服机器人的工程复盘。架构两半:服务端一个模型网关,客户端一个跑在客服工位上的本地程序,日均处理数千条客户进线。上线半年出了十来次事故,摊开根因清单,没有一次是"模型能力不够"。下面按链路顺序,从协议重建一路讲到成本台账,把每一道支架的来历、实现和价格算给你看。文中所有系统、场景、话术均已匿名化处理,留下的只有工程事实。
先把全文的层次盘清楚:
| 层 | 回答的问题 | 装了什么 |
|---|---|---|
| 模型层 | 生成什么内容 | 选型、版本档位、采样参数 |
| 提示词层 | 用什么规则约束生成 | 系统提示、安全红线、输出契约、few-shot 示例 |
| 支架层 | 生成的内容能否安全、准确、可负担地到达客户 | 协议重建、输出清洗、上下文卫生、发送闸门、失败兜底、成本台账 |
| 通道层 | 消息怎么落地 | 常规业务,本文不展开 |
提示词工程管"让模型说对",支架工程管"模型说错、说歪、说得不合形态的时候,事情不要扩散"。模型输出是中间品,不是交付品;任何要碰客户的东西,必须先过这层支架。 这是全文所有动作背后的那条判断标准。
半年里摔的坑,先集中列一遍,后面逐个展开:
| 事故 | 表象 | 根因所在层 |
|---|---|---|
| 一 | 逐字刷屏,打开旧会话卡死十几秒 | 协议重建 |
| 二 | 模型的工作记号原样发给了客户 | 输出清洗(哨兵守卫) |
| 三 | 语音回复把"微笑"两个字念了出来 | 输出清洗(标记枚举) |
| 四 | 后台配了回复风格,半个月不生效 | 组装完整性(风格没进请求体) |
| 五 | 账单和延迟一起翻倍,模型开始自说自话 | 上下文卫生 |
| 六 | 客户问一句,收到两条几乎一样的回复 | 上下文卫生(污染回灌) |
| 七 | 71 秒内连发 4 份报价单素材 | 闸门账目(关键词误伤) |
| 八 | "这条没回"查无实据,日志全绿 | 对账缺失 |
八件事,八个都发生在模型外面。这就是"支架"二字的全部含义。
一、流式协议重建:不要透传,重建一遍
客服机器人是全链路流式的:模型逐 token 生成,网关逐帧转发,客户端逐段渲染,客户看到的是打字机效果。早期版本里,网关做的事非常薄——透传。上游回什么样,下游就发什么样,一个字节不改。这个实现看起来干净,实际上是把上游的全部习惯直接搬上了客服工位。
头一桩事故出在帧粒度上。上游是每 token 一帧,每帧八十来个字节。客户端的消息存储把收到的碎片按列表存下来,单条消息能攒出四十多个碎片;半个月累积下来,一个会话里躺着六万多个碎片对象。然后反馈就来了:打开一个稍旧的会话,界面卡死十几秒。碎片化同时毁掉了三样东西——存储体积、渲染性能,以及直观的观感:聊天窗口逐字刷屏,客户消息界面成了打字机展览。
第二次事故出在一个字段的草率习惯上。协议里有个收尾原因字段,回合没结束时应该是空值,结束了变成合法枚举。上游在回合没结束时填的是一个空字符串,而不是真正的空值。客户端的判据是"这个字段非空即回合结束"——空字符串恰好通得过"非空"这个判断,于是每一帧都被当成收尾帧,渲染层把每个碎片当独立段落起行,一个字占一行。上游的习惯问题,客户端判据的毫厘之差,两个"差不多"合在一起,就是一起肉眼可见的事故。
摆在桌上的修法有三种。其一,在转发路径上逐帧打补丁:空串归一、超大帧合并、缺字段补默认。我们一开始就是这么干的,结果转发函数越补越肥,两百多行,每见一个脏字段加一条分支,每条分支又带来新的边界条件。其二,改客户端判据:把"非空即结束"改成"在合法枚举集合内才算结束"。可行,但只治了一个字段。其三,让转发路径整体退役:网关不透传上游任何一帧,按业界通行的流式协议形态,从本地重建一条全新的下游流。
我们选了第三种,效果是釜底抽薪式的。重建规则五条:
- 惰性首帧:角色起始帧只在真的有内容要发之前才发,流开头不存在空壳帧。
- 思考内容整段下发:推理型上游的中间内容不进缓冲窗口,攒齐了整段给。
- 正文微窗合并:内容帧进缓冲区,三个条件任一满足就冲刷成一个下游帧——120 毫秒到期、攒满 48 帧、攒满 2048 字符。三个参数全部走环境变量,调成全 0 就退化为逐帧透传,留着调试用。
- 收尾三件套:先冲刷缓冲区,再发收尾帧(收尾原因只会是合法枚举),再发用量帧(不带候选项),末尾一行
[DONE]。 - 同流同 id:一条流上所有帧共用同一个标识,客户端不需要做拼接猜谜。
// stream-harness.js —— 不透传上游帧,下游流由网关按协议重建
const MICRO_WINDOW = {
ms: Number(process.env.HARNESS_WINDOW_MS ?? 120),
frames: Number(process.env.HARNESS_WINDOW_FRAMES ?? 48),
chars: Number(process.env.HARNESS_WINDOW_CHARS ?? 2048),
};
function createDownstream(res, id) {
let sentFirst = false;
const send = (chunk) => res.write(`data: ${JSON.stringify(chunk)}\n\n`);
const ensureFirst = () => {
if (sentFirst) return;
sentFirst = true;
send({ id, choices: [{ index: 0, delta: { role: 'assistant', content: '' }, finish_reason: null }] });
};
let buf = '', bufFrames = 0, timer = null;
const flush = () => {
if (timer) { clearTimeout(timer); timer = null; }
if (!buf) return;
send({ id, choices: [{ index: 0, delta: { content: buf }, finish_reason: null }] });
buf = ''; bufFrames = 0;
};
const pushContent = (text) => { // 正文进微窗,不逐帧外发
ensureFirst();
buf += text; bufFrames += 1;
if (bufFrames >= MICRO_WINDOW.frames || buf.length >= MICRO_WINDOW.chars) flush();
else if (!timer) timer = setTimeout(flush, MICRO_WINDOW.ms);
};
const pushReasoning = (whole) => { // 思考内容整段下发
ensureFirst();
send({ id, choices: [{ index: 0, delta: { reasoning: whole }, finish_reason: null }] });
};
const finish = (usage) => {
flush();
send({ id, choices: [{ index: 0, delta: {}, finish_reason: 'stop' }] });
if (usage) send({ id, choices: [], usage });
res.write('data: [DONE]\n\n');
};
return { pushContent, pushReasoning, finish };
}
改完之后拿同一个会话回放对比,帧数变化值得记进台账:
| 场景 | 上游帧数 | 透传时下游帧数 | 重建后下游帧数 |
|---|---|---|---|
| 长思考流 | 182 | 182 | 6 |
| 114 字短回复 | 63 | 63 | 4 |
| 普通问答 | 11 | 11 | 5 |
帧数量的断崖背后,更重要的变化是性质上的:空串收尾、空壳帧、碎片存储这三类问题,从"被修复"变成了"在结构上不可能出现"。 修复一个 bug 是打补丁,消灭一类 bug 是改形状。
从那天起立了一条硬规矩:下游流的形状是网关对客户侧的承诺,不是上游的习惯。 上游给什么,都要重建成承诺过的形状;没承诺过的字段,不该靠赌它不出现来过日子。附带的半条是:不要再往转发函数里逐帧加修补逻辑——旧的那套 normalize 思路后来被整段删掉了,一边重建一边又留着逐帧打补丁的入口,等于给下一个人递铲子。
二、输出清洗:模型原文到客户气泡之间,隔着一整条链
这一层管的是:模型说的话,在变成客户看到的一条消息之前,要过哪几道处理。核心是三件套加两道守卫,再加一个反面教训。
| 清洗项 | 动作 | 为什么 | 踩过的坑 |
|---|---|---|---|
| 尾部标点剥离 | 去掉回复末尾的句号、叹号等 | 气泡配打字动效,句尾句号看起来像话没说完 | 剥离集是产品行为,不是工程卫生,改字符要过确认 |
| 非 BMP 字符剔除 | 移除码位高于 0xFFFF 的字符 | 表情类字符在下游发送链上会引发难以定位的异常 | 只在日志里看见过一次,不代表只有一种形态 |
| 换行折叠(输入侧) | 客户多行消息折成单行再喂模型 | 多行会被模型当成多轮发言,还虚增上下文 | 折叠只对客户消息做,模型输出不折 |
| 哨兵守卫 | 模型标记不外发,翻译成路由动作 | 标记直达客户等于把小抄发出去 | 守卫范围漏一类,就有一类标记漏到线上 |
| 念稿标记剥离 | 语音合成前剥掉各形态表情标记 | 三种形态只处理一种,等于没处理 | 见事故三 |
尾部标点看着是雕花,实际是口径。剥离集是逐字符列出来的,里面有个耐人寻味的细节:全角叹号在集合里,半角叹号不在。乍看觉得是漏配,顺手就想"改进"它;跟产品侧对了一遍才知道是原始口径,客户用半角叹号问的句尾要保留语气。清洗规则里任何一处"看起来像笔误"的东西,先当它是别人的业务,查证之后再动。 后来我们把"剥离集变更需产品确认"写进了改动检查清单。
非 BMP 字符是另一类麻烦。它头一回露头不是日志报的错,而是客服反馈"有些消息发出去对面是乱码,有些干脆没到"。码位在一万之后的字符,对下游链路是兼容性负担,对客服场景又不承担任何信息量——句子末尾丢了个表情,没人投诉;消息卡在发送链里出不去,客户才真的要炸。策略定得很干脆:整类剥离,不做白名单。
哨兵守卫必须单独讲。提示词里跟模型有契约:答不了的内容输出 [NO_ANSWER],需要人工跟进的输出 [TRANSFER_HUMAN],需要发素材的输出 [SEND_ASSET]。这些记号是给内部路由看的信号,不是话术。早期版本守卫范围没盖全,有一类记号漏了出去,客户收到一条"【NO_ANSWER】请稍等"。模型的小抄被当众念了一遍。从那以后信号一律走内部路由翻译,绝不允许原文触达客户。
事故三是"形态枚举"的典型。语音客服功能上线后,反馈说机器人把"微笑"两个字念了出来。定位很快:模型输出里带了个 (微笑),文字链路无所谓,语音链路的合成引擎老老实实把括号里的内容读了出来。检查初版标记剥离代码,发现它只处理方括号形态 [微笑]——中英文圆括号、以及 unicode 表情符号本体,两类全漏。同一种"表情记号"有三种形态,事故只暴露了其中一种;按事故修形态,下一种形态的事故会按排期来。清洗规则的枚举必须按"同一语义的全部形态"来列,不是按"这次出事的形态"来列。
然后是事故四,风格链断裂。商家后台有"回复风格"配置:自然、专业、简洁、智能追问,外加自定义档位。有客户连续半个月投诉"风格设了没用"。排查结论让人哭笑不得:这个字段从后台保存之后,从来没有进过模型的请求体——网关读到了配置,提示词装配函数压根没有这个参数。风格是提示词层的东西,但"风格到底有没有被装配进去"是支架层的东西。修复分三步:请求体补字段、提示词装配把风格映射成具体指令段、加一条端到端断言——测试直接检查最终构造出的提示词,断言风格指令必须在场。支架层里断裂的组装不会抛异常,程序跑得健健康康,只是产品行为跟说明书无关。 这类静默断裂,只有靠"配置字段必须出现在最终请求里"的断言才能兜住。
// sanitize.js —— 模型原文与"可发送"之间隔着这几行代码
const TRAIL_STRIP = '。!?;,、'; // 逐字符对齐线上口径,增删须产品确认
const SENTINELS = ['[NO_ANSWER]', '[TRANSFER_HUMAN]', '[SEND_ASSET]'];
function sanitizeAiOutput(raw) {
let s = String(raw).trim();
s = s.replace(/[\u{10000}-\u{10FFFF}]/gu, ''); // 非 BMP 整类剥离
s = s.replace(/[ \t]+\n/g, '\n').replace(/\n{2,}/g, '\n'); // 多余空行
while (s.length && TRAIL_STRIP.includes(s.at(-1))) s = s.slice(0, -1); // 尾部标点
return s;
}
function guardSignal(text) {
for (const tag of SENTINELS) {
if (text.includes(tag)) return { kind: 'signal', tag }; // 信号转内部路由,不外发
}
return { kind: 'reply', text };
}
// 语音链路专用:方括号、圆括号、unicode 本体,三种形态一个不能漏
function stripForSpeech(text) {
return text
.replace(/\[[^\]]{1,8}\]/g, '')
.replace(/[((][^()()]{1,8}[))]/g, '')
.replace(/[\u{1F000}-\u{1FAFF}\u{2600}-\u{27BF}\u{2B00}-\u{2B5F}\u{2190}-\u{21FF}\u{FE0F}\u{200D}]/gu, '')
.trim();
}
// 输入侧:客户多行消息折成一行,别让模型把一条消息读成多轮
const foldCustomerNewlines = (s) => s.replace(/\n+/g, ',');
顺带一条发送形态的账:长回复拆气泡策略曾经是"超五十五字试切三段",叠加逐条应答,客户问一句收到过四条。支架不只是把内容洗干净,还得把"发几条、多长一条"管住——后来气泡段数恒定两个上限,提示词里同时加了说话分寸的硬约束,一进一出两头收。
三、上下文卫生:存进去什么,决定模型以为发生过什么
机器人需要短期记忆:这个会话聊到哪了。这份记忆每一轮都要组装进请求,所以"往里存什么"是一个又省钱又保命的问题,而它的早期答案错得离谱。
旧方案把每一轮的"完整请求文本"原样存进会话历史——里面混着知识库检索块和教材示例,图的是下一轮省一次检索。结果在两个方向上同时爆炸。
成本方向:设计时假设历史消息平均二百四十来字,实测单条能到一千五百字,大头全是知识库块。十轮历史每轮重发一遍,客户发一条二十个字的"有红色 M 码吗",模型收到的是几千字的输入。账单翻倍,延迟翻倍,而且这两件事是同时发生的。
认知方向更微妙:检索块存进历史之后,在模型看来就是"客户说过的话"。模型读到"客户说:本品质保政策为整机一年……"——客户从没说过这话,那是我们自己注入的教材。于是模型开始自问自答,把教材当成客户立场去推理,答出来的东西客户一脸茫然。知识库内容进提示词是检索,进上下文是伪造客户发言,两个动作差一个字,性质完全不同。
事故六是上下文污染的另一种通道。有一轮模型生成了回复,但发送环节失败了——没发出去。这条回复的文本却照常写进了上下文。下一轮重试,模型在历史里看到自己"说过"几乎相同的话,于是又生成一条高度相似的内容,这回发出去了。客户侧的感受是:问一句,收到两条复读机。根因一句话:上下文里存了"想说",而不是"说出口的"。
修复动作拆成三条,共同的原则是上下文尽可能接近"真实发生事件的记录":
- 上下文只存两类东西——客户原话,和实际送达给客户且已清洗的回复。
- 检索每轮现做,结果只进本轮请求体,不落地成历史。
- 被拦截、发送失败、含污染残片的回复一律不入库,用中性占位替代;历史存量里已经混进的脏东西,在每轮组装出口再滤一遍。入口拒收加出口过滤,双保险。
| 污染通道 | 什么东西进了上下文 | 后果 | 封堵方式 |
|---|---|---|---|
| 检索块冒充客户发言 | 知识库教材、同义词块 | 模型自说自话;每轮重发,token 翻倍 | 请求侧字段分离;上下文只存原始消息 |
| 失败回复回灌 | 从未送达的模型原文 | 重试复读,客户收到两条一样的话 | 送达判定后才入库;未送达用占位 |
| 残片长期滞留 | 早期版本已污染的存量历史 | 升级后旧会话仍然复发 | 入口拒收 + 出口过滤双保险 |
// context.js —— 上下文只存"真实发生过的事"
function onCustomerMessage(session, rawText) {
session.context.push({ role: 'user', text: foldCustomerNewlines(rawText), source: 'raw' });
}
async function answerTurn(session) {
const last = session.context.at(-1).text;
const kb = await retrieve(last); // 检索每轮现做,不持久化
const req = {
system: buildSystemPrompt({ persona: session.persona, style: session.replyStyle }),
history: session.context.filter((m) => !m.junkHit).map(toMessage), // 出口滤存量
user: withKbBlock(last, kb), // 检索结果只进本轮
};
const out = await gateway.chat(req);
const cleaned = sanitizeAiOutput(out.text);
if (out.delivered) {
session.context.push({ role: 'assistant', text: cleaned, source: 'sent' });
} else {
// 关键:未送达的原文绝不入库,否则下一轮照着它复读
session.context.push({ role: 'assistant', text: FALLBACK_NOTE, source: 'placeholder' });
}
}
改造上线当晚对了一遍账:上下文体积单条从平均一千二百字掉到不足二百五十字,十轮会话的输入 token 量能省七成上下,模型"自说自话"类反馈同周清零。这是全文所有改动里投入产出比靠前的一条,因为它同时省钱和提质。
四、发送前的闸门:每一道闸都有价签
支架层里容易加、但也难算清楚的就是闸门——每一个 if 看起来都在让系统更安全,但它们 collectively 在消耗客户等待的耐心。这一节讲一笔账:什么样的闸该有,什么样的闸该拆,以及拆的时候怎么下决心。
先看事故七,一次代价明确的误伤。业务需求本身没毛病:客户索要报价单、说明书这类素材时,机器人要能把对应文件发出去。早期实现是关键词闸门:消息里同时出现"发"和"报价",判定为素材请求,触发发送。直到有一天,一位客户在 71 秒里连收 4 份报价单。日志回放还原了全过程:客户前面收到过一份素材介绍,于是回了一句"您刚发的报价单我先存着,主要想看这款的价格"——“发"和"报价"赫然在列,闸门触发;而这句话的语义是提及,不是请求。子串匹配分不清"请求"和"提到”,加上重试放大,一次误判变成四次连发。
桌上的修复方向有两个。方向一,收紧关键词:加排除词、加距离约束、加更长的词组。我们真走了一段,排除词列表越长,越发现子串方案在语义面前的无力——堵了一个误伤,漏一个更隐蔽的。方向二,把这道闸整个拆掉:意图判断交给模型,让模型在输出里带结构化标记(本轮需要发送某素材、素材编号几),发送链只对标记负责,标记再经一道"该素材是否允许发送"的末闸。最终执行了方向二,四百来行关键词判定逻辑连同它的测试整体移除。拆闸之后误发清零;漏发有没有?有,偶发一次,客户多说一句话下一轮就补上了。这两件事的代价不对称:误发是往客户脸上砸错东西,漏发是客户多等十来秒——不对称的代价面前,选轻的那头。
判断标准加粗贴在评审文档首页:关键词闸的问题从来不是命中率,而是判定粒度——子串承载不了意图。当一道闸误伤的代价是打扰真实客户时,错闸比没闸更糟。
事故七之后,团队立了两条闸门规矩,后来变成代码评审的默认问题:
- 评审任何新增校验之前,先算它的成本:花多少毫秒、额外调几次模型、多几次往返。回复延迟本身就是产品缺陷,不是可以拿"安全感"去换的东西。
- 加"发送前再核一次状态"这类闸之前,先问一句:它挡的是我们的 bug,还是我们控制范围之外、别人自己的动作?前者加,后者不加——系统的操作权本来就在流程手里,为"有人自己乱动了"设闸,挡不住真正的责任,只会把每一轮正常回复拖慢,让全部客户替个别事故买单。
第 2 条落地时撤回过三处我们自以为稳妥的防呆:发送前一瞬的会话身份复核、按键路径前置复核、输入框清理失败中止本轮。每道闸各付几十到一百多毫秒的状态核对,换来的是把客户等待时间拉长一截;而它们假设要防的事故场景,多数根本轮不到闸来背锅。撤闸的当晚,客服通道吞吐肉眼可见地变快。
留下来的闸,每一道都在账本上有名有姓:
// gates.js —— 每道闸显式标价格,评审时肉眼可见
const GATES = [
{ name: 'sentinel_guard', ms: 0, extraCalls: 0, blocks: '我们自己的 bug:标记直达客户', verdict: '保留' },
{ name: 'asset_sendable', ms: 2, extraCalls: 0, blocks: '素材高危动作发错', verdict: '保留,且作为素材发送的单一通道' },
{ name: 'context_junk_filter', ms: 1, extraCalls: 0, blocks: '污染引发的复读', verdict: '保留,入口出口各一道' },
{ name: 'keyword_intent', ms: 1, extraCalls: 0, blocks: '挡不住新话术,只制造误伤', verdict: '整体移除,意图交还模型' },
{ name: 'pre_send_recheck', ms: 90, extraCalls: 0, blocks: '控制范围外的用户动作', verdict: '撤回' },
];
| 闸 | 挡什么 | 每轮成本 | 最终处置 |
|---|---|---|---|
| 哨兵守卫 | 模型标记外发 | 微秒级 | 保留 |
| 素材可发送性末闸 | 不该发的文件进窗口 | 一次本地查库 | 保留,且是素材发送的必经通路 |
| 上下文污染过滤 | 复读事故 | 字符扫描 | 保留,入口 + 出口 |
| 关键词意图闸 | 低误伤率,高误触率 | 毫秒小,误伤代价大 | 移除,改信模型 |
| 发送前重复核对 | 别人的操作 | 每气泡几十毫秒起 | 撤回 |
闸门层的方法论浓缩成三问:这闸挡的是谁的责任?每轮付多少钱?误伤和漏拦的代价哪个重?三问答不上来,就别让这个 if 合进主干。
五、失败兜底与对账账本:7×24 系统的下限
7×24 意味着凌晨三点没有人在屏幕前。系统出事时必须自己知道该怎么办,并且事后说得清楚自己干了什么。这两件事,一件叫兜底,一件叫对账。
兜底是分层的,不是把"抱歉"贴满所有窟窿。一条回复从生成到送达,可能坏在格式、坏在内容、坏在链路,三种坏法对应三种降级:
| 层级 | 触发条件 | 客户看到什么 |
|---|---|---|
| 防御解析 | 想要的 JSON 被套了代码围栏、带了前言 | 无感知,剥离后正常走 |
| 修复调用 | 剥离后仍解析失败 | 无感知,额外付一次小模型调用,只许修格式不许改内容 |
| 安全话术 | 修复仍失败 | 营业时间、发货时效这类通用安全口径 |
| 反问澄清 | 信息不足以定位订单或商品 | “方便提供一下订单号吗” |
| 转人工 | 同类兜底连续两轮命中 | “已为您转接人工客服” |
每层都要留痕,且修复调用严格限一次——没有上限的重试链,在模型偶发抽风的那几分钟里能自己把账单和延迟烧穿。
对账账本源于一次很挫败的排查。反馈说"这个客户十点十五分那条,机器人没回"。我们翻日志:没有报错,没有异常,一切正常。问题是,那一轮到底是"判断过认为不需要回",还是"被锁冲突静默吞掉",还是"回了但客户没看见"?查不清楚。当时的日志哲学是只记错误,成功路径静默——成功路径零日志,等于只在出事故时才开监控。 这种体系里,"没回"不是一个状态,是一片黑箱。
改造后的规矩:每轮无论成败,都往对账表写一行;成功也写。锁超时、看门狗超时这类"静默丢弃"路径必须带原因。从此"没回"类问题变成一次查询的事:这个会话这个时刻,outcome 是什么,reason 是什么,延迟多少。
CREATE TABLE msg_trace (
trace_id TEXT PRIMARY KEY,
session_id TEXT NOT NULL,
ts INTEGER NOT NULL,
outcome TEXT NOT NULL, -- ok / skipped / failed
reason TEXT, -- skipped 与 failed 必填原因,ok 可不填
latency_ms INTEGER,
tokens_in INTEGER,
tokens_out INTEGER
);
-- 维护任务定期裁剪,保留窗口对齐客诉回溯周期
看门狗配合这块有一个带血的细节。系统自带无进展自愈重启,但旧版启动逻辑会清运行日志——自愈动作本身销毁了事故现场,重启之后什么证据都没剩。修复:自愈重启之前,先把"遗言"写进一个不随启动轮转的快照文件——当前阶段、无进展时长、近十二次状态迁移,重启后先读快照定位,再谈恢复。
function dumpWatchdogSnapshot(state) {
const lines = [
new Date().toISOString(),
`stage=${state.stage} stalled_ms=${state.sinceProgress}`,
...state.recentStates.slice(-12).map((s) => `${s.at} ${s.name}`),
'---',
];
fs.appendFileSync(WATCHDOG_SNAPSHOT, lines.join('\n') + '\n'); // 追加,不清空,不轮转
}
这一段所有东西合起来是一句话:在 7×24 系统里,"没回复"不是异常,是一种需要被记录、有名字、有时间戳、可被查询的正常状态。 兜底决定客户凌晨三点看到什么,对账决定第二天早上你知不知道自己看到了什么。
六、成本意识:支架同时是一本账
前面五节的事故里,至少三类(上下文重发、修复调用无上限、闸门过度设防)本质上都是成本事故,只是当时用延迟和账单的形式表现。支架层恰恰是成本意识能落地的地方:模型层决定质量,提示词层决定行为,只有这一层决定"每一个正确答案花多少钱、花多少毫秒"。
半年下来,台账长这样:
| 成本项 | 改造前 | 改造后 |
|---|---|---|
| 历史消息内容 | 检索块随每轮重发,单条可达 1500 字 | 只存原始消息,单条回到 250 字以内 |
| 十轮会话输入量 | 每轮把教材再吃一遍 | 输入量下降约七成,延迟同步下降 |
| 换上游模型档位 | 改代码、重新打包、发版 | 服务端配置一行加一次重启,客户端零改动 |
| 模型调用修复 | 无上限重试 | 每轮至多一次,二次失败直落兜底 |
| 闸门开销 | 每气泡重复核对 | 每道闸标价格,重复项合并,多数撤除 |
换模型档位那条值得多说一句。模型名做成环境变量、缺失即拒绝启动,这个改动本身不稀奇;稀奇的是它把"换模型"从一个发版事件变成了一个运维事件——成本优化、上游故障切档、新档位灰度,全部收敛在网关这一个位置完成。但便宜不等于随便:网关换的是流量走向,换来的行为漂移(格式偏好、拒答边界、确定性表达的强弱)必须照跑回归基准集才能放量,那是发布管理层面的事,这里点到为止。支架层负责让换模型这个动作便宜,便宜不能变成轻率的理由。
微窗合并严格说省的不是 token 钱,是渲染和存储的钱——但把"下游每帧平均体积"和"单会话碎片总量"拉进周报之后,客户端侧的卡顿工单量、会话库膨胀速度头一回有了可优化的目标值。成本意识的意思是:支架层每一项工作都要能翻译成账单上的两行字,一行钱,一行毫秒。
七、全景链路与落地清单
把全文的支架按数据流的位置画成一条链,每个环节对应上文的章节:
客户消息
→ 上下文装配 只存原始消息;多行折叠;检索每轮现做 (第三节)
→ 请求构建 人设/风格/安全红线全量注入 + 端到端断言 (第二节·事故四)
→ 模型网关 上游流不透传,协议重建 + 微窗合并 (协议重建一节)
→ 输出清洗 哨兵守卫 → 非 BMP → 尾部标点 → 形态约束 (第二节)
→ 发送前闸门 只保留拦"我们的 bug"的闸,每闸标价 (第四节)
→ 送达
→ 清洗后入上下文 未送达用占位,绝不回灌原文 (第三节)
→ 每轮对账 ok / skipped / failed + 原因 + 耗时 + 用量 (第五节)
照着能做的清单,按投入产出排序。前三条不做完,后面的收益都会打折:
| 序号 | 做什么 | 验收标准 | 优先级 |
|---|---|---|---|
| 1 | 下游流协议重建,禁止透传上游帧 | 抓流断言:无空壳帧,收尾值恒为合法枚举 | 必须 |
| 2 | 流式合并窗口,参数可调 | 帧数对比表进周报,逐字刷屏类工单归零 | 必须 |
| 3 | 输出清洗三件套 + 哨兵守卫 | 任何模型标记不可达客户路径,有测试钉死 | 必须 |
| 4 | 上下文只存原始消息与送达文本 | 单条历史长度回到百字量级,可逐轮抽查 | 必须 |
| 5 | 配置字段端到端断言 | 任何提示词组装项,测试能证明它进了最终请求 | 必须 |
| 6 | 每轮对账落表,成功也写 | 任选一个客诉时刻,一次查询讲清"回没回、为什么" | 必须 |
| 7 | 闸门成本账本 | 每道闸有毫秒与调用数标注,新闸评审先过三问 | 应当 |
| 8 | 修复调用与重试严格限次 | 上游抽风窗口内,账单曲线无尖峰 | 应当 |
| 9 | 兜底分层,拒答与转人工有独立口径 | 抽检兜底回复,无一条"万能抱歉" | 应当 |
| 10 | 模型档位配置化,切换走环境变量 | 换档不发版;换档必过回归基准 | 值得 |
八、常见问题
Q:harness 层和传统中间件、网关的区别到底是什么?为什么不直接拿现成框架解决?
A:传统中间件处理的是确定性的字节与协议,harness 处理的是一个概率性的、会说人话的组件的产物。协议重建可以用通用网关思路做,但哨兵守卫、清洗枚举、污染过滤、兜底分层,这些逻辑里全是业务语义,没有任何现成框架替你决定"半角叹号该不该剥"。框架给的是骨架,harness 填的是你对自己产品的理解。
Q:把关键词意图闸拆了改信模型,模型误判意图的时候不是更失控?
A:两类错误的代价结构不一样。关键词闸的错误形态是"语义完全无关的句子触发发送",错得干脆利落;模型的错误形态需要它"理解偏了",而标记之外还有一道素材可发送性末闸。真实数据是拆闸后误发清零,漏发偶发且下一轮自愈。信模型不等于裸奔,是把判断权交给更强的判断者,同时在动作出口再保留一道确定性检查兜底。
Q:微窗合并会不会把首字延迟拉长?窗口参数怎么定?
A:会,代价是首帧至多晚 120 毫秒左右——对打字机效果而言不可感知,对"逐字刷屏"而言是质变。窗口参数别拍脑袋,拿真实流量的帧间隔分布定:窗口要盖住上游连续帧的典型间隔,又不能超过人能忍受的段落刷新周期。三个参数全部做成环境变量,出问题时调到全零即可退化回逐帧,留一条逃生通道。
Q:小团队起步,六块支架先做哪块?
A:按"事故概率乘以单次代价"排。协议重建和输出清洗先做,这两个直接决定客户看到什么,而且事故形态肉眼可见;接着做上下文卫生,它同时是成本和正确性问题,回本快;然后是对账账本,它不防事故但让所有事故便宜;闸门和成本分层放后面——它们的前提恰恰是你有了账本,能算出每道闸值多少钱。
Q:清洗规则到底该让模型自己遵守,还是程序兜底?
A:两层都要,但责任不同。提示词里写清楚格式契约(不输出句尾标点、不外露标记),这是让模型大概率一次做对;程序侧的清洗是确定性兜底,假设模型一定会在某些样本上违约。契约写给模型看,闸门写给违约看,任何一边单独存在都会在长尾上翻车。换模型档位时这条尤其重要:违约的方式每次都换新花样。
Q:对账表每轮都写,量大之后存储怎么办?
A:字段设计克制一点,一行几十字节,日进千轮量级的系统一个月不到一张图片的大小。保留窗口对齐客诉回溯周期,过期裁剪进归档。真正该心疼的不是这张表的存储,而是没有这张表时那些查不清、赔了客户情绪的晚上。
写在结尾
回到开头那个判断。模型不是产品,提示词也不是产品——客户收到什么、多快收到、说错时怎么收场,全部由模型外面那圈支架决定。这半年这台客服机器人身上,支架层做的事没有一件是"更聪明的提示词"能替代的:协议要重建,因为上游习惯不可托付;输出要清洗,因为模型违约是常态不是例外;上下文要守卫生,因为存进去的每一粒沙子都会在下一轮被模型当成事实;闸门要算账,因为每一毫秒的过度防呆都由等待中的客户埋单;失败要分层兜底并且事事留痕,因为凌晨三点没有人在场。
Prompt Engineering 教我们怎么跟模型说话,Harness Engineering 教我们怎么替模型善后。前者是文科,后者是 plumbing——而 7×24 在线系统的口碑,恰恰是 plumbing 决定的。这台机器人至今仍在跑,模型换过档,提示词改过几十版,支架层那八条链路的形状基本没再动过——好的支架就是这样:装好之后,存在感为零。
相关阅读:
- 《LLMOps:发布原子不是模型,是提示词/语料/模型三元组》
- 《消息去重与幂等:从指纹账本到布隆过滤器的三级防线》
DAMO开发者矩阵,由阿里巴巴达摩院和中国互联网协会联合发起,致力于探讨最前沿的技术趋势与应用成果,搭建高质量的交流与分享平台,推动技术创新与产业应用链接,围绕“人工智能与新型计算”构建开放共享的开发者生态。
更多推荐

所有评论(0)