用无状态回调搭建 AI 电话接待
作者拆解电话接待系统的完整链路:呼叫转接、语音转写、LLM 生成回复,再通过 Webhook 循环推进对话。实现采用无服务器后端和无状态轮次设计,通话结束后向商家发送需求及预约摘要。
AI 深度解读
电话没人接,小商家可能就丢掉一笔生意。作者为加拿大小企业做了 AI 电话接待产品 Aethely,思路很直接:用 Twilio 接听和转写,用 LLM 决定下一句,再把来电需求整理成短信发给店主。
这套方案值得看的地方,是它把语音接待拆成了普通 HTTP 请求,并用结构化输出衔接业务流程。不过,无数据库状态和模型驱动转接都有适用前提,原文展示的是实现经验,没有提供效果对照或性能数据。
一通电话,怎么变成一组 HTTP 请求
客户拨打商家原来的号码,无人接听或已经下班时,运营商把电话转到系统控制的号码。Twilio 随后请求来电 webhook,后端返回 XML,告诉它先说什么、再听什么。
下面这段代码解决开场和收集语音的问题,也明确告知来电者正在与 AI 交流:
<Response>
<Gather input="speech" action="/api/voice/turn?s=STATE"
speechTimeout="auto" language="en-CA" actionOnEmptyResult="true">
<Say>Hi, thanks for calling Northside Heating. I'm the virtual assistant,
an AI. How can I help?</Say>
</Gather>
</Response>
要看的是 actionOnEmptyResult="true":即使对方没说话,后端也能收到回调,自行处理沉默。speechTimeout="auto" 用于语音收集的超时判断,language="en-CA" 对应加拿大英语。
之后,Twilio 把转写文本交给 /api/voice/turn。后端带上商家资料和对话历史询问 LLM,再返回下一句和继续监听的指令,循环直到通话结束。
每一步都是独立的 HTTP 请求,适合放进 serverless functions。这里的「无状态」指函数实例不必记住上一次调用,对话历史仍然需要有地方存放。
把历史塞进 URL,省掉数据库要付出什么
常见做法是按 call ID,把历史存进数据库或缓存。作者选择压缩历史,再放进回调 URL 的 s 参数,让下一次函数调用直接拿到完整上下文。
下面两个函数完成状态的压缩编码与还原:
import { deflateRawSync, inflateRawSync } from 'node:zlib';
export const packState = (obj) =>
deflateRawSync(Buffer.from(JSON.stringify(obj))).toString('base64url');
export const unpackState = (s) =>
JSON.parse(inflateRawSync(Buffer.from(s, 'base64url')).toString());
要看的是 unpackState 中的解压和 JSON.parse:输入来自 URL,不能直接当成可信对象。作者也明确要求限制轮数,并校验解包后的内容。
这个取舍依赖一个具体条件:作者称,典型通话少于十轮,压缩后的历史足够小,因此可以省掉数据库和清理任务。但原文没有给出实际字节数、URL 长度上限,或长通话超限后的处理。
另外,base64url 是编码,不是加密。若历史包含姓名、电话和地址,还得考虑 URL 在日志中的留存。Webhook 验签与状态内容校验也要分别做,前者不能代替后者。
模型负责表达,代码负责执行
作者没有让后端从一段自然语言里猜测「是不是要转人工」,而是要求模型返回符合 schema 的 JSON:
say是下一句要说的话,end_call表示是否结束。urgent表示是否符合店主定义的紧急规则,transfer表示是否立即转接。details包含name、callback、need、address、booking,用于整理短信。
这五个顶层字段都列入 required。后端读取 transfer: true 后执行 <Dial>,读取 end_call: true 后挂断并发送摘要,业务分支不必依赖文本解析。
结构化输出能固定数据形状,但不能保证模型判断正确。
原文的 schema 也没有把 details 内的字段设为必填。因此,不能假定每次都能拿到完整联系方式和预约信息。作者没有给出所用模型、结构化输出 API、校验失败和重试代码,这些部分仍需补齐。
提示词则主要规定边界:营业时间、服务、允许公开的价格、预约窗口和紧急规则。信息不足就承认不知道并留言,不编造价格、空档和承诺,不提供医疗、法律或财务建议,回复保持简短。
失败分支同样属于主流程。第一次沉默询问是否还在,第二次沉默礼貌结束,并发送已收集的信息;通话结束的 status callback 则处理只持续几秒或未接通的情况,给来电者补发商家短信。
安全方面,每个 webhook 在处理前验证服务商签名,作者描述为对 URL 和参数计算 HMAC。延迟方面,他建议缩短模型输出、缓存提示词,并把摘要短信放到通话结束后发送。原文没有给出缓存实现或延迟测量结果。
我的判断
我认同作者对交付物的判断:店主真正需要的是一条能据此行动的短信,里面写清谁来电、需要什么、多紧急、预约了什么。原文示例把 Jordan、(780) 555-0142、暖炉故障和当天 2–4 PM 的预约或请求集中在一起,比让店主重新听留言更直接。
但「请求」和「已预约」必须分清。原文没有展示日历写入或预约确认过程,不能据此认定系统已经完成真实预约。
无数据库方案适合短对话和轻量流程;如果需要查询历史、恢复通话或审计,按 call ID 存储通常更容易管理。结构化输出优于解析自由文本,但转接、挂断等动作仍值得由后端检查规则。
至于「边界让店主信任系统」,这是作者的产品观察。原文没有提供接通率、转化率、成本或错误率的对照数据,不能据此判断它优于人工接听或普通语音留言。
能拿来做什么
- 用 Node 做电话接待原型时,先跑通来电 webhook、语音转写回调和下一轮响应,再接入 LLM,分别验证电话链路与模型输出。
- 给日常 AI 编程中的业务助手定义明确 schema,把回复内容和动作字段分开,并为字段缺失、校验失败、超时准备处理路径。
- 采用 URL 携带状态时,同时设置轮数、输入大小和解压后大小限制;需要长期查询与审计时,改用 call ID 关联数据库或缓存。
- 验收时检查沉默、提前挂断、信息不足和紧急来电的结果,并确认短信能区分预约请求与已确认预约。