文章结合 WhatsApp 客服场景,讨论越界提问、人工接管、消息突发、旧会话恢复及模型故障等生产问题。其关键做法是将是否回复的分类判断与答案生成分开,并用强制工具调用返回枚举结果。
WhatsApp 上的 AI Agent,演示起来总是很漂亮。你贴上一段业务介绍,问一句“你们几点开门?”,它就会用一句友好的话回答。然后,真正的客户来了。他们会问竞品的问题,发送语音消息,用全大写输入“HUMAN!!”,在五秒内连发三条消息,还会回复三天前的对话。模型服务商也会有状态不佳的时候。还有客户会要求机器人忽略自己的指令。
这篇文章会介绍,为了让 AI Agent 在 WAPGROWAI 的 WhatsApp 收件箱里稳定运行,我们围绕模型构建了哪些东西,以及我们认为每个团队在启用 Agent 之前都应该想清楚的问题。文中的 TypeScript 代码片段是这些实现模式的小型简化版本,每段都针对它所描述的场景运行验证过。
最重要的设计决策是:负责生成回答的模型,不应该同时负责决定要不要回答。
每条收到的消息,首先都会交给一次独立、低成本的分类调用,返回三个标签之一:回答、拒绝,或者转交人工。标签通过带有 enum 的强制工具调用返回,temperature 设为 0,因此结果绝不会是需要你自行解析的自由文本:
// scope.ts
export type Scope = "in_scope" | "out_of_scope" | "wants_human";
// The model must call this tool; free text is never used as the label.
export const LABEL_TOOL = {
type: "function",
function: {
name: "label_message",
parameters: {
type: "object",
properties: { scope: { type: "string", enum: ["in_scope", "out_of_scope", "wants_human"] } },
required: ["scope"],
},
},
};
type CallTool = (system: string, user: string, tool: typeof LABEL_TOOL) => Promise<{ scope?: string }>;
export const classifyScope = async (message: string, businessName: string, callTool: CallTool): Promise<Scope> => {
const system =
`Label one customer message for ${businessName}. ` +
"wants_human: they ask for a person or are clearly frustrated. " +
"out_of_scope: unrelated to this business. When unsure, choose in_scope.";
try {
const { scope } = await callTool(system, message.slice(0, 2000), LABEL_TOOL);
return scope === "out_of_scope" || scope === "wants_human" ? scope : "in_scope";
} catch {
return "in_scope"; // fail open: a classifier outage shouldn't silence the agent
}
};
export const HANDOFF_LINE = "I'll pass you to someone from our team — one moment.";
export const refusalLine = (businessName: string) =>
`Sorry, I can only help with questions about ${businessName}. What can I help you with?`;
这里有两个选择,是我们有意做出的权衡:
拒绝和转人工的回复使用固定字符串,不由模型生成。偏离业务范围或带有敌意的消息,根本不会进入负责生成回答的模型,也就没有机会诱导它。当“忽略你的指令,然后……”得到的回复,是你自己写好的一句话时,prompt injection 能钻的空子就少得多。
分类器出错时,默认放行。如果调用出错,就把消息视为业务范围内的问题,交给回答模型;回答模型自身的 prompt 仍会限制它只处理与业务有关的问题。另一种选择是分类器稍有故障就保持沉默,我们认为这对客户体验更糟。如果你选择另一种做法,也应该是主动决策,而不是意外形成的行为。
分类器只看当前这条消息。这样既快又便宜,也正因如此,我们要求它在不确定时选择 in_scope:如果没有上下文,单独一个“是”字没有任何意义。
WAPGROWAI 的 Agent 根据一份由商家编写或上传的业务资料回答问题:卖什么、营业时间、配送和退货规则、愿意公开报价的价格,以及机器人绝不能作出的承诺。每次调用时,整份资料都会放进 system prompt,并附上规则:事实、价格和营业时间必须来自这份资料,Agent 不能下单、收款或预约。
我们选择不为此使用 retrieval,也就是不做分块、embeddings 和向量存储。小商家的业务资料通常只有几页,而且资料长度上限是 200,000 个字符。整份发送,可以避免索引过期,也能避免检索步骤悄无声息地漏掉最关键的那一段。代价是每次调用都要消耗这些 token;如果你的知识库很大,就需要留意这项权衡。
我们还要求模型使用客户的语言,最多用三个短句回答。在 WhatsApp 上,一大堵文字往往不如简短的回答。
模型不会记住对话。每次调用时,你都要根据已经存储的消息重新构建上下文,而其中的细节很重要:
// history.ts
type StoredMessage = { direction: "in" | "out"; type: string; text?: string };
type Turn = { role: "user" | "assistant"; content: string };
const PLACEHOLDER: Record<string, string> = {
audio: "[sent a voice message]",
image: "[sent an image]",
video: "[sent a video]",
document: "[sent a document]",
template: "[sent a template message]",
};
// Everything the business sent — AI, chatbot or a human agent — is an "assistant" turn.
export const buildHistory = (messages: StoredMessage[], maxTurns = 16, maxChars = 2000): Turn[] =>
messages
.map((m): Turn | null => {
const content = m.text?.trim() || PLACEHOLDER[m.type];
if (!content) return null;
return { role: m.direction === "in" ? "user" : "assistant", content: content.slice(0, maxChars) };
})
.filter((t): t is Turn => t !== null)
.slice(-maxTurns);
商家发出的所有消息,都算作 assistant 轮次,包括人工客服输入的消息。如果一个小时前有人回答过某个问题,AI 就必须看到那个回答,否则它可能给出相互矛盾的说法。
媒体消息用占位文本表示。模型看不到语音或照片,但应该知道客户发过这些内容,因此历史记录里会放入 "[sent a voice message]",而不是直接留空。
限制轮次数和字符数。旧上下文会消耗 token,却很少有帮助;限定窗口大小,可以让成本和延迟都更可预测。
客户常常会连续发消息:“你好”“你们送货到浦那吗”“运费多少”。如果每条消息都发起一次独立的模型调用,这些调用会以随机顺序完成,客户就会收到三条内容重叠的回答。
// lock.ts
const inFlight = new Set<string>();
// One reply per conversation at a time. A message that arrives mid-reply is skipped,
// not queued: the reply being generated already reads the latest history.
export const withConversationLock = async <T>(conversationId: string, job: () => Promise<T>): Promise<T | null> => {
if (inFlight.has(conversationId)) return null;
inFlight.add(conversationId);
try {
return await job();
} finally {
inFlight.delete(conversationId);
}
};
生成回复期间收到的消息会跳过,不会排队——正在生成的回复会读取历史记录,所以通常能覆盖这些消息。这是进程内的锁;如果你运行多个实例,就需要通过共享资源提供同样的保证,例如数据库中的一行记录,或者一个 Redis key。生成期间,WAPGROWAI 还会显示 WhatsApp 的“正在输入”提示,这样几秒钟的思考时间就不会显得毫无回应。
Agent 调用 Cloudflare Workers AI 上兼容 OpenAI 的 endpoint,并使用 NVIDIA 托管的模型作为后备。要让故障切换真正有用,关键在于区别对待不同错误:
// failover.ts
export type ErrorClass = "transient" | "quota" | "fatal";
export type Provider = { name: string; call: () => Promise<string>; classify: (error: unknown) => ErrorClass };
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
// Transient: retry here, then move on. Quota: move on now. Fatal (a bad request): stop,
// because another provider would reject it too.
export const callWithFailover = async (providers: Provider[], attempts = 2, delayMs = 400): Promise<string> => {
let lastError: unknown;
for (const provider of providers) {
for (let attempt = 1; attempt <= attempts; attempt++) {
try {
return await provider.call();
} catch (error) {
lastError = error;
const kind = provider.classify(error);
if (kind === "fatal") throw error;
if (kind === "quota") break;
if (attempt < attempts) await sleep(delayMs * attempt);
}
}
}
throw lastError;
};
临时错误,例如超时、容量不足:先在同一服务商上做短暂重试,再切换到下一个。
配额耗尽或模型不可用:重试没有意义,立即切换。
致命错误,也就是请求本身被拒绝:停止调用。把格式错误的请求再发给第二家服务商,只会让失败次数翻倍。
每种任务也都有自己的预算:分类器的 temperature 设为 0,超时时间较短;回答调用可以多给一些余量;生成聊天机器人流程这类长任务,则会给到几分钟。
模型输出 Markdown。WhatsApp 不渲染标题,而且用单个星号表示粗体。发送前先转换格式,并在句子边界处限制长度:
// whatsapp-format.ts
const MAX_REPLY_CHARS = 900;
// Models write Markdown; WhatsApp uses *bold* and has no headings.
export const toWhatsApp = (reply: string): string => {
let text = reply
.replace(/\*\*(.+?)\*\*/g, "*$1*")
.replace(/__(.+?)__/g, "*$1*")
.replace(/^#{1,6}\s+/gm, "")
.trim();
if (text.length > MAX_REPLY_CHARS) {
const cut = text.slice(0, MAX_REPLY_CHARS);
const lastStop = Math.max(cut.lastIndexOf(". "), cut.lastIndexOf("? "), cut.lastIndexOf("! "));
text = lastStop > 0 ? cut.slice(0, lastStop + 1) : cut;
}
return text;
};
还有一种值得添加的过滤:某些推理模型偶尔会返回思维链,而不是回答。检测到这种模式时,应该丢弃内容,别把它发给客户。
一个收件箱通常会同时运行多种自动化。如果没有明确的顺序,关键词规则和 AI Agent 就可能同时回复同一条消息。WAPGROWAI 对每条入站消息的处理顺序是:
优先处理客户对商家所发按钮和表单的回复,例如订单结账表单或模板按钮。
接着执行启动聊天机器人的规则——关键词始终可以把客户带入结构化流程。
已经运行中的聊天机器人继续执行。
只有在这些步骤之后,如果当前对话启用了 AI Agent,才由它回答。
AI Agent 接管一段对话期间,其他规则不会对这段对话中的消息作出响应。
确定性流程优先于 AI,AI 则优先于其余所有处理。不管你选择什么顺序,都要写下来——客户收到两条回复时,别人首先就会问你这个问题。
这些问题没有唯一正确答案,但每一个都需要经过明确决策:
语音和图片。是转录、回复请客户发送文字,还是转交人工?一个只支持文本的 Agent,收到语音后什么都不说,看起来就像坏了。
模型彻底失败时怎么办。发送一句简短的“我们会稍后回复您”,转交人工,还是保持沉默,等待人工接手?
谁来接手转人工的对话。聊天会分配给某个人吗?有人会收到通知吗?还是进入共享队列等待处理?没人看到的转人工,比根本没有 AI 更糟。
什么会结束 AI 模式。客户要求人工、某条规则、某个按钮——还有,队友回复消息或分配聊天时,是否应该自动结束 AI 模式?
24 小时窗口。回复客户没有问题,但对于已经沉寂好几天的聊天,AI 编写的问候消息会被 WhatsApp 拒绝;主动发送消息需要使用模板。
成本和上限。每条 AI 回复同时也是一条 WhatsApp 服务消息,而 Meta 现在会对每个号码每月超过 1,000 条的部分收费。要提前决定达到月度上限后怎么办。
数据。客户消息会发送给你的模型服务商。你需要清楚数据去了哪里,并在隐私政策里说明。
先分类,使用独立调用,并为“不回答”和“请转人工”准备固定回复。
让 Agent 以一份精简文档为依据,并告诉它绝不能作出哪些承诺。
从自己的存储中重建历史记录,包括人工说过的话。
同一段对话,一次只生成一条回复。
根据错误类型切换服务商,不要盲目切换。
适配 WhatsApp 的格式,并过滤泄露的推理内容。
把自动化的执行顺序写下来。
模型是容易的那部分。生产环境里真正要做的,是决定它什么时候开口、能说什么,以及不该开口时该怎么办的所有配套工作。
我参与开发 WAPGROWAI,这个 Agent 就运行在它的团队收件箱里。如果想从非技术角度了解 AI Agent 应该和不应该为商家做什么,可以阅读 Medium 上的配套文章。本系列的其他文章还涉及 webhooks、24 小时窗口、模板与营销活动,以及共存模式。
如需采取进一步措施,你可以考虑屏蔽此人和/或举报滥用行为。