跳到正文
Hacker News· iobert·· 10 天前AI 评分32

如何将 AI 智能体的会话记录与长期客户事实分离:Twilio Conversations 与 Vercel eve 实践

Separating an agent's session transcript from long-term customer facts

AI 导读

Twilio Conversations 与 Vercel 的 eve 框架组合,把 AI 智能体的短期记忆(当前会话记录)与长期记忆(客户画像与自动提取的观察事实)拆成两层:eve 保存当前线程的紧凑 transcript,Twilio Memory Store 跨渠道持久化客户事实,会话开始时由动态指令合并两者。

正文 · AI 翻译

阅读时间:

2026年10月8日

作者

审核者

使用 Twilio Conversations 和 eve 构建多渠道 AI 智能体

使用 Twilio Conversations 和 eve 构建多渠道 AI 智能体

让 AI 智能体回复客户的短信很容易。但让它在下周客户通过另一个渠道发消息时记得这位客户?大多数智能体项目就是在这里放弃,然后开始自己编写记忆层。

Twilio 通过我们今年早些时候推出的一款工具帮你省去这一步:Twilio Conversations。将它搭配 Vercel 的 eve 框架,两者共同为智能体所需的两种记忆划出了清晰的界限:

  1. Vercel 的 eve:保存短期记忆,即当前对话的实时记录
  2. Twilio Conversations:保存长期记忆,即关于当前这个人的知识(自动提取的客户画像和观察记录)

有了这两个工具,你就可以把时间花在智能体的行为上,而不是底层管道上。现在你可以接入任何你想要的 LLM。

在本文中,你将构建一个智能体,它把 SMS 和 WhatsApp 统一到一个会话线程中,记住每位客户上个月告诉你的内容,并且只需一行代码就能更换模型。

想先浏览代码?完整示例项目在 GitHub 上:IObert/twilio-conversations-eve-agent。克隆它,安装依赖,把你的凭证和服务 ID 填入 .env,本文的其余部分就是对已有内容的导览。

你将构建什么

设想这样一个场景:一位客户给你的支持热线发短信,询问他们订购的蓝色跑鞋的退货政策。十天后,他们通过 WhatsApp 给你发消息,跟进同一笔订单,并提出一个新问题。

关于这位客户的有趣事实包括他们的姓名、购买的产品、他们偏好通过 WhatsApp 处理紧急事务,以及他们最近提出的几个问题。如果你只持久化这些事实,并有选择地注入相关事实,提示词就能保持精简、可调试,并在各会话之间保持稳定。

这就是你将在下文构建的关注点分离。eve 为当前会话线程保留一份紧凑的每对话记录。Twilio 的 Memory Store 保存跨所有过往会话线程提取出的事实。一条动态指令会在每次会话开始时合并这两类信息。

An architecture diagram reading left to right. On the left, one customer with a single phone number sends over two channels, SMS and WhatsApp. Both feed into a red-bordered "Twilio Conversations" panel holding three pieces: Orchestrator (one profile per person, one conversation across every channel), Memory Store (who the customer is and what they've said), and Intelligence (extracts the facts when a conversation closes, writing them back to the Memory Store). The panel is labeled "long-term memory". Two arrows cross to a blue-bordered "eve" panel: a red `COMMUNICATION_CREATED` webhook and a dashed blue arrow carrying the customer profile. In eve, `channels/twilio-orchestrator.ts` is highlighted as the custom channel you write; everything below it is marked "baked in" — `instructions.md` for the system prompt, `agent.ts` for any AI SDK model, `tools/` and `skills/` for what the agent can do, and the session transcript as short-term memory. A blue path runs back to the customer: the reply goes out on whichever channel they just used.

An architecture diagram reading left to right. On the left, one customer with a single phone number sends over two channels, SMS and WhatsApp. Both feed into a red-bordered "Twilio Conversations" panel holding three pieces: Orchestrator (one profile per person, one conversation across every channel), Memory Store (who the customer is and what they've said), and Intelligence (extracts the facts when a conversation closes, writing them back to the Memory Store). The panel is labeled "long-term memory". Two arrows cross to a blue-bordered "eve" panel: a red `COMMUNICATION_CREATED` webhook and a dashed blue arrow carrying the customer profile. In eve, `channels/twilio-orchestrator.ts` is highlighted as the custom channel you write; everything below it is marked "baked in" — `instructions.md` for the system prompt, `agent.ts` for any AI SDK model, `tools/` and `skills/` for what the agent can do, and the session transcript as short-term memory. A blue path runs back to the customer: the reply goes out on whichever channel they just used.

图 1:架构图,展示 Twilio Conversations 与 Vercel 的 eve 如何相互集成

认识技术栈

eve 是 Vercel 推出的文件系统优先框架,用于构建持久化 AI 智能体。你编写文件,eve 将它们编译成一个运行时,负责管理会话、工具调用、流式传输,以及跨崩溃和重新部署的持久状态。该项目也在 GitHub 上开源。

Twilio Conversations 是我们的一套AI 原生产品,超越了传统通信 API 的范畴。在它之下有三个部分:

  • Conversation Orchestrator:一种持久化的跨渠道对话资源,将 SMS、WhatsApp、RCS、聊天和语音拼接为每位客户的一个会话线程。作为开发者,你决定应如何处理每个渠道上的流量,以及是否将其连接到下面另外两个 Conversations 子产品。
  • Memory Store:一个持久化的、按客户划分的档案数据库,包含特征(如姓名、电话、偏好)和观察记录(如从过往对话中提取的事实)。
  • Conversation Intelligence:对对话运行语言算子,用于生成摘要、情感分析和观察提取,可实时进行,也可在对话结束时进行。

需要牢记的心智模型:Orchestrator 负责回答“这是谁,我该在哪里回复?”这个问题。Memory Store 负责回答“我已经知道关于他们的哪些信息?”这个问题,而 Intelligence 会将新的事实写入 Memory Store。

前提条件

开始之前,你需要准备一些东西:

  • Node.js 24 或更高版本
  • 一个 Twilio 账户,带有 Account SID 和 Auth Token(两者均可在 Twilio Console 中找到)
  • 至少一个支持短信的 Twilio 电话号码
  • 一个绑定到电话号码的 WhatsApp 发送者。它可以是与短信相同的号码,也可以是第二个专用号码。两者都适用于此设置。
  • 一个 OpenAI API 密钥,或你选择的其他提供商(任何 AI SDK 提供商都可以)
  • 用于执行配置调用的 shell。macOS 和 Linux 自带 curl。在 Windows 上,要原样运行 curl 版本,请使用 WSL 或 Git Bash。
  • ngrok 或类似的本地 webhook 隧道工具。ngrok 是一个为你提供一个公共 HTTPS URL 并将其转发到笔记本电脑上某个端口的工具,这样在你开发期间 Twilio 就能访问你的 webhook。

引导项目

让我们初始化一个新项目,并获取所需的所有包。在终端中,使用以下命令:

复制代码

npx eve@latest init elephant-agent
cd elephant-agent
pnpm add @ai-sdk/openai twilio

系统可能会询问你是否“安装以下包”,并提及 eve 的最新版本。此时按“y”安装 eve 即可。

twilio npm 包是你稍后用来验证 Orchestrator webhook 签名的工具。@ai-sdk/openai 是你将用来接入模型的 AI SDK 提供商。

eve init 会生成项目脚手架,包含一个 hello-world 智能体所需的一切:

复制代码

elephant-agent/
├── agent/
│   ├── agent.ts          # model + runtime config
│   ├── channels/
│   │   └── eve.ts        # local CLI channel for pnpm dev
│   └── instructions.md   # system prompt
├── package.json
└── tsconfig.json

对于本教程,默认的 instructions.md 就足够了。

你可以根据智能体的需要,用额外的槽位来扩展指令:

向导已经创建了 agent/channels/eve.ts,它暴露了一个本地 CLI 通道:运行 pnpm dev,你就会得到一个交互式终端,无需接触任何外部服务即可与智能体聊天。你的 Twilio 通道将与它一起放在同一个文件夹中。

配置模型

现在让我们配置 agent/agent.ts 文件。用你选择的编辑器打开该文件,你会看到文件内容如下:

复制代码

import { openai } from "@ai-sdk/openai";
import { defineAgent } from "eve";
export default defineAgent({
  model: openai("gpt-5.6-luna"),
  modelContextWindowTokens: 400_000,
});

要使用其他提供商,请安装其 AI SDK 包,并替换文件中引用 model 的位置。

针对零数据保留组织的说明。如果你的 OpenAI 组织启用了零数据保留,默认的 Responses API 调用会失败,并提示 “Items are not persisted for Zero Data Retention organizations.” 在配置中添加 modelOptions: { providerOptions: { openai: { store: false } } } 即可正常工作。

设置环境变量

在项目根目录创建一个 .env 文件。下面的注释会告诉你每个值来自哪里,所以 你可以先填写顶部区块,然后再进行下一步

复制代码

# .env
# ── Fill in now ─────────────────────────────────────────────
# Twilio credentials. Both live on the Twilio Console dashboard:
# https://console.twilio.com (Account SID at the top, Auth Token below).
TWILIO_ACCOUNT_SID=ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
TWILIO_AUTH_TOKEN=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
# Model provider. Create a key at https://platform.openai.com/api-keys
OPENAI_API_KEY=sk-...
# Twilio senders
TWILIO_SMS_NUMBER=+15551234567
TWILIO_WHATSAPP_NUMBER=whatsapp:+15551234567
# ── Fill in after starting ngrok ─────────────
PUBLIC_BASE_URL=
# ── Fill in after Twilio provisioning (a few sections down) ─
MEMORY_STORE_ID=
ORCHESTRATOR_CONFIG_ID=

暂时将最后两个留空,因为你将在下面几步中填写它们。

在配置之前启动 ngrok

在创建 Orchestrator Configuration 之前,你需要一个公共 HTTPS URL,因为该 curl 调用会注册 Twilio 将调用的 webhook URL。如果你使用的是免费的 ngrok 账户,它每次启动都会获得一个新的随机主机名。现在启动隧道,并将 URL 作为 PUBLIC_BASE_URL 粘贴到 .env 中:

复制代码

ngrok http 2000

从 ngrok 输出中复制 https://…ngrok… 主机名,将其作为 PUBLIC_BASE_URL 设置到 .env 中。

文件保存到磁盘后,将每个变量加载到当前 shell 中,以便 curl 和 pnpm dev 看到相同的值:

复制代码

set -a && source .env && set +a

配置 Twilio 侧

Memory Store 和 Orchestrator Configuration 是一次性设置。两者都是 REST 资源。

创建 Memory Store

现在让我们创建 Memory Store。在终端中,使用以下命令:

复制代码

curl -s -X POST "https://memory.twilio.com/v1/ControlPlane/Stores" \
  -u "$TWILIO_AUTH_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"displayName":"elephant-agent-store"}'

响应是 202,带有 statusUrl,看起来像 https://memory.twilio.com/v1/ControlPlane/Operations/mem_configop_XXXXXXXX。这是 Twilio 表示“我已接受该任务,准备好时请检查此 URL”的方式。使用你自己的响应中的 statusUrl 进行简单的“GET”确认(将下面的占位符替换为你的 op_... id):

复制代码

curl   -u "$TWILIO_AUTH_TOKEN" \
  "https://memory.twilio.com/v1/ControlPlane/Operations/op_XXXXXXXX"

在响应正文中查找 "status": "COMPLETED" 和 mem_store_... id。将 id 作为 MEMORY_STORE_ID 复制到 .env 中,然后重新加载 shell:

复制代码

set -a && source .env && set +a

使用 GROUP_BY_PROFILE 创建 Orchestrator Configuration

现在你的请求所需的所有值都在你的环境中了。使用 heredoc,以便 Bash 内联替换变量。 

复制代码

curl -s -X POST "https://conversations.twilio.com/v2/ControlPlane/Configurations" \
  -u "$TWILIO_ACCOUNT_SID:$TWILIO_AUTH_TOKEN" \
  -H "Content-Type: application/json" \
  --data-binary @- <<EOF
{
  "displayName": "elephant-agent-config",
  "description": "Cross-channel Conversation Orchestrator for the elephant-agent memory demo. Groups SMS and WhatsApp traffic by customer profile and hands turns to the eve runtime.",
  "conversationGroupingType": "GROUP_BY_PROFILE",
  "memoryStoreId": "$MEMORY_STORE_ID",
  "memoryExtractionEnabled": true,
  "channelSettings": {
    "SMS": {
      "captureRules": [
        {"from": "*", "to": "$TWILIO_SMS_NUMBER", "metadata": {}},
        {"from": "$TWILIO_SMS_NUMBER", "to": "*", "metadata": {}}
      ]
    },
    "WHATSAPP": {
      "captureRules": [
        {"from": "*", "to": "$TWILIO_WHATSAPP_NUMBER", "metadata": {}},
        {"from": "$TWILIO_WHATSAPP_NUMBER", "to": "*", "metadata": {}}
      ]
    }
  },
  "statusCallbacks": [
    {"method": "POST", "url": "$PUBLIC_BASE_URL/eve/v1/twilio-orchestrator/webhook"}
  ]
}
EOF

有两个设置值得特别说明:

  • conversationGroupingType: GROUP_BY_PROFILE 将跨渠道的同一客户统一为一个对话,以 Memory Store 配置文件为键,而不是以客户使用的地址为键。默认值 GROUP_BY_PARTICIPANT_ADDRESSES_AND_CHANNEL_TYPE 将 SMS 和 WhatsApp 分隔为单独的对话。GROUP_BY_PARTICIPANT_ADDRESSES 将渠道分组在一起,但仅当客户在每个渠道上使用 相同 的地址时。这意味着,如果客户从一个号码发送 SMS,但从另一个号码发送 WhatsApp 消息,那么这些渠道将不会被分组。GROUP_BY_PROFILE 是 Twilio 推荐用于生产环境的设置。
  • memoryExtractionEnabled: true 开启智能管道,在对话关闭时将观察结果写回 Memory Store。

注意:不要在电话号码或 WhatsApp 发送者上配置 webhook。 上面的 captureRules 才是引入流量的东西,而单个 statusCallbacks URL 是 Orchestrator 传递流量的地方。

一旦下面的一切都连接好,这就是 GROUP_BY_PROFILE 为你带来的好处:

Two dark-mode phone screenshots side by side. The left is an SMS thread where the customer mentions blue running shoes and asks about the return policy. The right is a WhatsApp thread ten days later where the customer writes only "Quick follow up on that order: Can I still return them?" and the agent replies with the return window for that shoe order, without the customer having repeated any details.

Two dark-mode phone screenshots side by side. The left is an SMS thread where the customer mentions blue running shoes and asks about the return policy. The right is a WhatsApp thread ten days later where the customer writes only "Quick follow up on that order: Can I still return them?" and the agent replies with the return window for that shoe order, without the customer having repeated any details.

图 2:一个连续的对话,从左侧通过 SMS 开始,并在右侧通过 WhatsApp 继续

请注意,WhatsApp 消息不包含有关产品、订单的信息,也没有任何先前对话的提醒。该上下文来自 Memory Store 配置文件,Conversation Orchestrator 将两个渠道分组到该配置文件中。

此调用也返回 202,带有 statusUrl。你可以用同样的方式确认该调用,替换为你自己的 op_... id:

复制代码

curl -u "$TWILIO_ACCOUNT_SID:$TWILIO_AUTH_TOKEN" \
  "https://conversations.twilio.com/v2/ControlPlane/Operations/op_XXXXXXXX"

一旦响应显示 "status": "COMPLETED",将 conv_configuration_... id 作为 ORCHESTRATOR_CONFIG_ID 复制到 .env 中。

首次尝试:eve 的内置 Twilio 适配器

在编写任何自定义内容之前,先尝试 eve 的内置 Twilio 适配器:

复制代码

// agent/channels/twilio.ts
import { twilioChannel } from "eve/channels/twilio";
export default twilioChannel({
  allowFrom: "*",
  messaging: { from: "*" },
  async onText(ctx, message) {
    await ctx.twilio.sendMessage(`Copy: ${message.body}`);
    return null;
  },
});

将你的 Twilio 电话号码的入站消息 webhook 指向 $PUBLIC_BASE_URL/eve/v1/twilio/messages,运行 pnpm dev,然后给你的号码发短信。太棒了,你只用几行代码就构建了一个可用的 SMS 代理!

A dark-mode SMS thread titled "Twilio Conversations". A green outgoing bubble reads "Hi 👋 This is a test"; the gray reply below it reads "Copy: Hi 👋 This is a test".

A dark-mode SMS thread titled "Twilio Conversations". A green outgoing bubble reads "Hi 👋 This is a test"; the gray reply below it reads "Copy: Hi 👋 This is a test".

图 3:一个绿色的发出气泡显示“Hi 👋 This is a test”;下方的灰色回复重复了该消息

如果你创建了这个文件,请在继续下一步之前删除它。 清除你刚刚在电话号码上设置的入站 webhook,并删除 agent/channels/twilio.ts 文件。 Orchestrator 设置通过捕获规则摄取流量,如果每个号码的 webhook 仍指向内置适配器,那么每条入站消息都会触发两条路径,你的客户就会收到两条回复。

Eve 的 Twilio 适配器是一个组织良好的集群,由 packages/eve/src/public/channels/twilio 下的八个 TypeScript 模块组成,负责处理:

  • Webhook 签名验证,对照 Twilio 的 HMAC 方案
  • 解析表单编码的正文(SMS、MMS 元数据、语音转录回调)
  • 构建延续令牌,以便呼叫者在消息之间保持同一会话
  • 一个 sendMessage 辅助函数,从入站中解析回复的 from 和 to
  • 通过 <Gather> 和状态回调支持语音
  • 为 message.completed 和 turn.failed 提供默认处理程序,让你开箱即用获得可用的回复

对于概念验证来说,这非常出色。但如果你想推进你的用例,可能会遇到问题:这个适配器既不支持媒体,也没有提供有用的语音集成,而且所有会话都绑定到发送者地址,这意味着 SMS 和 WhatsApp 渠道是分开的。你基本上失去了 Twilio Conversations 提供的一切。 因此,为了避免这种情况,让我们编写自己的适配器,让代理能够记住客户在之前对话中告诉你的内容。

构建一个 Orchestrator 感知的渠道

Twilio Messaging API webhook 在每条传入消息时触发,你可以从其自身的表单编码负载中推断出详细信息。Conversation Orchestrator 位于上一层,你订阅 Orchestrator 配置本身的事件,每条消息(入站或出站)和其他事件类型都会到达一个以 conversationId 为键的 JSON webhook。

这与 eve 内置适配器处理的 webhook 形状不同。所以让我们编写一个适配器来处理 JSON 负载。

导入和设置

创建 agent/channels/twilio-orchestrator.ts,包含以下部分:

复制代码

import { defineChannel, POST } from "eve/channels";
import { resolveTwilioAuthToken, sendTwilioMessage, type TwilioChannelCredentials } from "eve/channels/twilio";
import twilio from "twilio";
import { linkCrossChannelIdentity } from "../lib/memory";
const publicOrigin = process.env.PUBLIC_BASE_URL!;
// Left empty on purpose: eve's Twilio helpers fall back to TWILIO_ACCOUNT_SID
// and TWILIO_AUTH_TOKEN from the environment. Fill this in only if you need to
// override them per-channel (a subaccount, or an API key pair).
const credentials: TwilioChannelCredentials = {};
// Passive capture rules also fire on our own outbound, so filter echoes.
// Your senders are known at startup, so this set is static. Both the bare and
// the whatsapp:-prefixed form of each, since the webhook may report either.
const AGENT_ADDRESSES = new Set<string>(
  [process.env.TWILIO_SMS_NUMBER!, process.env.TWILIO_WHATSAPP_NUMBER!].flatMap((a) => {
    const bare = a.startsWith("whatsapp:") ? a.slice("whatsapp:".length) : a;
    return [bare, `whatsapp:${bare}`];
  }),
);
// Shape of the Orchestrator webhook body we actually read. Extend as
// you handle more event types.
type WebhookEvent = {
  eventType: string;
  data?: {
    conversationId?: string;
    author?: { address?: string; channel?: string; participantId?: string };
    content?: { type: "TEXT" | "TRANSCRIPTION"; text?: string };
    recipients?: Array<{ address?: string; channel?: string }>;
    // PARTICIPANT_ADDED shape
    type?: string;
    profileId?: string;
    addresses?: Array<{ channel?: string; address?: string }>;
  };
};
// The routing info we stash on auth.attributes so it refreshes on every send.
type RouteAttrs = {
  channel?: string;
  customerAddress?: string;
  agentAddress?: string;
};
// Twilio expects the "whatsapp:" prefix on outbound WhatsApp addresses.
function withChannel(channel: string | undefined, address: string): string {
  if (channel === "WHATSAPP" && !address.startsWith("whatsapp:")) return `whatsapp:${address}`;
  return address;
}
// ...continues below

在上面的代码中,这些是主要组件:

  • AGENT_ADDRESSES 是回声过滤器,在启动时构建一次(详见下文)。
  • process.env.PUBLIC_BASE_URL 上的 ! 是向 TypeScript 承诺该变量已设置。如果你忘记填写它,签名验证会与 undefined/eve/... 进行比较。在生产环境中,将 ! 替换为会抛出错误的真实启动检查。
  • 这里声明了两个类型和 withChannel 辅助函数,以便文件的其余部分可以自由引用它们。继续阅读以了解每个组件在哪里使用。

验证 webhook

eve/channels/twilio 中的默认 verifyTwilioRequest 实现了 Twilio 的基于表单参数的 HMAC 方案。这是每个经典 Twilio Messaging 或 Voice webhook 使用的方案。然而,Orchestrator webhook 是不同的。

对于 Conversation Orchestrator,正文是 JSON,而不是表单编码。Twilio 附加一个 bodySHA256 查询参数,并针对原始正文字节对 URL 进行签名。因此,你需要打开渠道定义及其第一个路由:

复制代码

// ...continued from above
function toPublicUrl(request: Request): string {
  const url = new URL(request.url);
  return `{url.pathname}${url.search}`;
}
export default defineChannel({
  routes: [
    POST("/eve/v1/twilio-orchestrator/webhook", async (request, { from, waitUntil }) => {
      const rawBody = await request.text();
      const signature = request.headers.get("x-twilio-signature") ?? "";
      const authToken = await resolveTwilioAuthToken(credentials.authToken);
      if (!twilio.validateRequestWithBody(authToken, signature, toPublicUrl(request), rawBody)) {
        return new Response("unauthorized", { status: 401 });
      }
      // ...continues below

twilio Node SDK 的 validateRequestWithBody 会读取一次原始请求体,验证它,然后解析它。toPublicUrl 重建了 Twilio 实际调用的 URL(使用 PUBLIC_BASE_URL)。

解析并过滤回声

Orchestrator 会发送许多事件类型(CONVERSATION_CREATED、PARTICIPANT_ADDED、COMMUNICATION_CREATED 等)。

这里我们最关心的是两个:

  • PARTICIPANT_ADDED:当客户加入 Conversation 时触发,它是用于跨渠道关联其身份的钩子(详见下文)。
  • COMMUNICATION_CREATED:携带消息正文。

捕获规则是双向的。当你的 agent 发送回复时,Orchestrator 也会捕获 那条 消息,并向你的 webhook 投递一个新的 COMMUNICATION_CREATED 事件。

如果没有过滤器,agent 就会回复自己的回复,永无止境。(问我怎么知道的!)

这就是 AGENT_ADDRESSES 的用途——它是一个在启动时构建的静态集合:你唯一会用来发送的地址就是 .env 中已有的那两个发送者。

TWILIO_WHATSAPP_NUMBER 带有 whatsapp: 前缀,因为出站发送需要它,但 webhook 并不会始终以相同形式报告作者。将其规范化为纯号码并同时存储两种写法,意味着过滤器无论哪种方式都能匹配。

复制代码

// ...continued from above
      const ok = new Response("ok", { status: 200 });
      const event = JSON.parse(rawBody) as WebhookEvent;
      // Under GROUP_BY_PROFILE, hydrate the sibling identifier on the CUSTOMER's
      // profile so the next inbound on the other channel joins the same conversation.
      if (event.eventType === "PARTICIPANT_ADDED") {
        const d = event.data;
        const addr = d?.addresses?.[0];
        if (d?.type === "CUSTOMER" && d.profileId && addr?.address) {
          waitUntil(linkCrossChannelIdentity(d.profileId, addr.address, addr.channel));
        }
        return ok;
      }
      if (event.eventType !== "COMMUNICATION_CREATED") return ok;
      const { conversationId, author, content, recipients } = event.data ?? {};
      const customerAddress = author?.address;
      // Voice arrives as TRANSCRIPTION; skip until voice replies are wired up.
      const text = content?.type === "TEXT" ? content.text : undefined;
      if (!conversationId || !customerAddress || !text) return ok;
      if (AGENT_ADDRESSES.has(customerAddress)) return ok;
      // ...continues below

Twilio Memory 通过身份特征来解析客户:SMS、Voice、RCS 和 MMS 在 phone 下查找,WhatsApp 在 whatsapp 下查找。

同一个人在两个渠道上会创建两个档案(并落入两个独立的对话!),除非你在第一个档案创建的确切时刻填充其兄弟标识符。这就是 linkCrossChannelIdentity 所做的事。

分发该轮次

回复渠道请使用 auth.attributes,而不是 state eve 的 state 是粘性的。它在会话创建时设置一次,并在每个后续轮次中保持不变地复用。 auth.attributes 在每次 send 时刷新,所以如果你把渠道和地址放在那里,eve 就会在客户刚刚使用的线路上回复。

应用到你的分发中:

复制代码

// ...continued from above
      const recipient = recipients?.[0];
      const channel = author?.channel ?? recipient?.channel;
      const agentAddress = recipient?.address;
      waitUntil(
        from(conversationId).send(text, {
          auth: {
            attributes: {
              customerAddress,
              ...(channel ? { channel } : {}),
              ...(agentAddress ? { agentAddress } : {}),
            },
            authenticator: "twilio-orchestrator-webhook",
            issuer: "twilio",
            principalId: `twilio-orchestrator:${author?.participantId ?? customerAddress}`,
            principalType: "user",
          },
        }),
      );
      return ok;
    }),
  ],
  // ...continues below

注意:发送选项中没有任何 state。回复所需的一切都在 auth.attributes 上,它会在每一轮刷新。

participantId 来自 Twilio Conversations:它是 Orchestrator 对某个 Conversation 内某个参与者的 id。principalId 来自 eve,是 eve 保留在会话上的应用级参与者标签,以便你的代码能区分调用者。

participantId 的作用域是 Twilio Memory 中的对话,这正是 eve 会话所具有的作用域。长期身份是 Memory Store 的职责,下一节会妥善地交接这一点。

在回复侧读取 auth.attributes

每当事件处理器需要与渠道通信时,就会调用 defineChannel 的 context 函数。你就是在那里读取最新的路由信息:

复制代码

// ...continued from above
  context(_state, session) {
    const { channel, customerAddress, agentAddress } =
      (session.auth.current?.attributes ?? {}) as RouteAttrs;
    return {
      async sendMessage(body: string) {
        if (!customerAddress || !agentAddress) return null;
        return sendTwilioMessage({
          credentials,
          to: withChannel(channel, customerAddress),
          from: withChannel(channel, agentAddress),
          body,
        });
      },
    };
  },
  // ...continues below

当渠道是 WhatsApp 且地址尚未带有 whatsapp: 前缀时,withChannel 会重新添加该前缀。Twilio 在出站时需要它以确保正确路由。

将事件连接到发送

最后,我们需要告诉 eve,当模型完成一轮时,你希望将完成的消息通过 sendMessage 路由:

复制代码

// ...continued from above
  events: {
    async "message.completed"(event, channel) {
      if (event.finishReason === "tool-calls" || !event.message) return;
      await channel.sendMessage(event.message);
    },
    async "turn.failed"(_event, channel) {
      await channel.sendMessage(
        "I hit an error while handling your request. Please try again.",
      );
    },
  },
});

就这样!渠道接收 Orchestrator webhook,过滤回声,将轮次分发到 eve,并在正确的渠道上路由回复。

用 Memory Store 丰富提示词

既然渠道已处理路由,让我们利用 Twilio 存储的客户知识来为每次模型调用提供条件。

Memory Store 辅助工具

创建一个新文件 agent/lib/memory.ts:

复制代码

const BASE = "https://memory.twilio.com/v1";
export function extractE164(raw: string | undefined): string | null {
 if (!raw) return null;
 const bare = raw.startsWith("whatsapp:") ? raw.slice("whatsapp:".length) : raw;
 return /^\+\d{7,15}$/.test(bare) ? bare : null;
}
function authHeader(): string | null {
 const { TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN } = process.env;
 if (!TWILIO_ACCOUNT_SID || !TWILIO_AUTH_TOKEN) return null;
 return `Basic ${Buffer.from(`${TWILIO_ACCOUNT_SID}:${TWILIO_AUTH_TOKEN}`).toString("base64")}`;
}
// Hydrate the sibling identifier (phone <-> whatsapp) so the same person
// on the other channel resolves to this profile on the next interaction.
// Under GROUP_BY_PROFILE that keeps SMS and WhatsApp in one conversation.
// See the blog post [TBD] for the full walkthrough.
export async function linkCrossChannelIdentity(
 profileId: string,
 address: string,
 channel: string | undefined,
): Promise<void> {
 const auth = authHeader();
 const storeId = process.env.MEMORY_STORE_ID;
 if (!auth || !storeId) return;
 const phone = extractE164(address);
 if (!phone) return;
 const sibling = channel === "WHATSAPP"
   ? { idType: "phone", value: phone }
   : { idType: "whatsapp", value: `whatsapp:${phone}` };
 await fetch(`${BASE}/Stores/${storeId}/Profiles/${profileId}/Identifiers`, {
   method: "POST",
   headers: { Authorization: auth, "Content-Type": "application/json" },
   body: JSON.stringify(sibling),
 });
}
// Twilio treats `phone` and `whatsapp` as separate identity types, so
// Orchestrator creates the profile under whichever channel arrived first.
// Try both when looking up.
async function lookupProfileId(storeId: string, auth: string, phone: string): Promise<string | null> {
 for (const [idType, value] of [
   ["phone", phone],
   ["whatsapp", `whatsapp:${phone}`],
 ] as const) {
   const res = await fetch(`${BASE}/Stores/${storeId}/Profiles/Lookup`, {
     method: "POST",
     headers: { Authorization: auth, "Content-Type": "application/json" },
     body: JSON.stringify({ idType, value }),
   });
   if (!res.ok) continue;
   const { profiles } = (await res.json()) as { profiles?: string[] };
   if (profiles?.[0]) return profiles[0];
 }
 return null;
}
export async function fetchCustomerContext(phoneE164: string) {
 const auth = authHeader();
 const storeId = process.env.MEMORY_STORE_ID;
 if (!auth || !storeId) return null;
 const profileId = await lookupProfileId(storeId, auth, phoneE164);
 if (!profileId) return null;
 const [profileRes, obsRes] = await Promise.all([
   fetch(`${BASE}/Stores/${storeId}/Profiles/${profileId}`, { headers: { Authorization: auth } }),
   fetch(`${BASE}/Stores/${storeId}/Profiles/${profileId}/Observations?PageSize=20`, { headers: { Authorization: auth } }),
 ]);
 const profile = profileRes.ok ? await profileRes.json() : null;
 const observations = obsRes.ok ? (await obsRes.json()).observations ?? [] : [];
 return { traits: profile?.traits ?? {}, observations };
}

你为 Memory Store REST API 使用基本认证,一个用于处理 whatsapp: 前缀小问题的小助手,linkCrossChannelIdentity 用于你在频道中看到的身份链接 POST,以及一个 Promise.all 以便配置文件和其观察结果并行返回。pageSize=20 上限对于这样的演练来说足够了。

动态指令

现在我们回到了之前的 agent/instructions/ 文件夹。

instructions.md 包含你手写的基础提示,而 instructions/* 文件夹中的文件在运行时添加内容。eve 的 动态指令 允许你在 session.started 或 turn.started 时将文本注入系统提示——我们这里将使用 session.started。

现在创建 instructions/customer-context.ts 文件。

复制代码

import { defineDynamic, defineInstructions } from "eve/instructions";
import { extractE164, fetchCustomerContext } from "../lib/memory";
export default defineDynamic({
  events: {
    "session.started": async (_event, ctx) => {
      const attrs = ctx.session.auth.initiator?.attributes as
        | { from?: string; customerAddress?: string }
        | undefined;
      const phone = extractE164(attrs?.from ?? attrs?.customerAddress);
      if (!phone) return null;
      const context = await fetchCustomerContext(phone);
      if (!context) return null;
      const lines: string[] = [];
      for (const [group, traits] of Object.entries(context.traits)) {
        for (const [key, value] of Object.entries(traits)) {
          if (value !== null && value !== undefined && value !== "") {
            lines.push(`{key}: ${typeof value === "string" ? value : JSON.stringify(value)}`);
          }
        }
      }
      if (context.observations.length > 0) {
        lines.push("recent_observations:");
        for (const obs of context.observations) lines.push(`- ${obs.content}`);
      }
      if (lines.length === 0) return null;
      return defineInstructions({
        content: ["<customer_context>", ...lines, "</customer_context>"].join("\n"),
      });
    },
  },
});

该块使得每个会话一次,模型看到封装的信息,如下所示:

复制代码

<customer_context>
Contact.firstName: Marius
Contact.phone: +49XXXXXXXXXX
recent_observations:
- Asked about the return policy for the blue running shoes
- Ordered blue running shoes
- Prefers the color blue
</customer_context>

<customer_context> 标签是我选择的格式,以帮助模型理解我们正在注入的内容。

Twilio 的 Intelligence 在上一次对话结束时自动提取了这些观察结果。最棒的部分是?你没有写一行提取代码。

在新客户的第一条消息上,Memory Store 查找 defineDynamic 返回 null,因此模型只看到你的基础 instructions.md。Twilio 在与客户的第一次对话结束后填充配置文件,因此上面的 <customer_context> 块会在与该客户的下一次对话开始时出现。

运行它

在新终端中启动代理(保持 ngrok 运行):

复制代码

pnpm dev

你应该看到类似这样的内容(尽管你的 eve 版本可能会有所不同):

复制代码

☰eve v0.66.1
[DEV] server listening at http://127.0.0.1:2000/

向你的 Twilio 号码发送一条包含一些信息的短信,以便 Intelligence 有材料稍后提取。类似这样:

“嗨,我上周订购了蓝色跑鞋。你们的退货政策是什么?”

你会在 eve 日志中看到 webhook 触发,模型调用发送到你的提供商,并在一两秒内回复。

现在,从同一部手机通过 WhatsApp 发送后续消息,并故意省略细节:

“实际上,我还能退货吗?”

“它们”是整个测试:你在第二条消息中小心地不透露任何信息,并使用不同的渠道。你应该在 WhatsApp 上收到回复,eve 将其视为同一会话,因为 Orchestrator 将两个渠道分组为一个 Conversation。当对话关闭时(Orchestrator 的不活动超时,或 显式 PATCH 到 status: CLOSED),Conversation Intelligence 从转录中提取观察结果并将其写入 Memory Store 配置文件。从上面的两条消息中,那就是“询问了蓝色跑鞋的退货政策”和“订购了蓝色跑鞋”。下次这个客户给你发消息时,无论是几天、几周还是几个月后?这些观察结果会在第一轮回到提示中。

如果什么都没发生,不要惊慌。试试这个: 打开 ngrok 检查器,地址为 http://127.0.0.1:4040,查找 POST 到 /eve/v1/twilio-orchestrator/webhook。如果是 401,则签名检查失败(通常是因为 PUBLIC_BASE_URL 与 Twilio 实际调用的 ngrok 主机不匹配)。如果是 200 且没有回复,请查看 pnpm dev 终端以获取模型调用。

窥视 Memory Store 内部

很酷,对吧?但在你欢呼之前,让我们看看 Twilio 为你构建的记忆。

打开 Twilio Console,点击 Orchestration 下的“Conversation Memory”,选择你已配置的存储(其 id 位于 MEMORY_STORE_ID 中的那个),然后点击你的电话号码首次向 agent 发送消息时创建的那个 profile。

该 profile 分为四个标签页:

  • Traits 保存结构化字段(姓名、电话、渠道身份)
  • Identifiers 列出 Orchestrator 归组到一起的地址
  • Summaries 保存每次对话的摘要
  • Observations 是 Intelligence 从对话记录中提取并链接回其来源对话的事实。

The Twilio Console Memory Store profile page with the Observations tab selected. A table with columns for Observation ID, Observation, Date created, and Last updated lists three rows: "Asked about the return policy for the blue running shoes", "Ordered blue running shoes", and "Prefers the color blue". Each observation ID is a link back to the source Conversation.

The Twilio Console Memory Store profile page with the Observations tab selected. A table with columns for Observation ID, Observation, Date created, and Last updated lists three rows: "Asked about the return policy for the blue running shoes", "Ordered blue running shoes", and "Prefers the color blue". Each observation ID is a link back to the source Conversation.

图 4:Twilio Console Memory Store 的 profile 页面,显示当前所有 observations

这里也是检查 Intelligence 已提取(或未提取)内容是否合理的地方。如果某个 observation 看起来不对,你可以从 Console 中将其删除。

请注意,Twilio 的每次对话超时通常以 分钟 到 小时 为单位(即 Orchestrator Config 上的 statusTimeouts.inactive 和 statusTimeouts.closed 字段),而 eve 的会话生命周期默认为 30 天(limits.sessionTimeoutMs)。当 Orchestrator 对话关闭时,客户的下一条消息会带着一个 新的 conversationId 到达,这意味着一个 新的 eve 会话和一份全新的对话记录。这是预期行为。在这些情况下,长期记忆已转移到 Twilio 的 Memory Store。如果你希望短期记忆延续更久,请提高 Orchestrator 的 closed 超时时间,让两个系统对对话何时真正结束达成一致。

接下来该往哪里走

如果你想进一步构建,在已经运行起 SMS 和 WhatsApp 基础之后,你可以做几件事:

  • 添加语音。 Twilio Conversation Relay 接入同一个 Orchestrator Configuration,因此同一个 agent 可以用相同的会话和记忆接听电话。
  • 发送更丰富的回复。 你的 agent 目前发送的每条消息都是纯文本。如果你希望在回复中包含图片、按钮或列表消息,只需定义一次 Content Template,然后给模型一个引用该模板 SID 来填充变量的工具。content types overview 包含你可以发送的完整菜单:twilio/media 用于图片,twilio/quick-reply 用于按钮,twilio/list-picker 用于可选列表,还有更多。

用 eve 和 Twilio Conversations 收尾

在短期记忆和长期记忆的概念之下,架构相当直接:一个渠道文件、一个记忆辅助工具和一个动态指令。要构建一个能跨对话和渠道记住客户上下文的动态多渠道 agent,你所需的一切 已经 存在于 Twilio 和 eve 中。

你将一个配置完善的 Orchestrator Configuration、一个带自动 observation 提取的 Memory Store,以及 eve 的会话模型串联成一条流水线,并构建了一个 agent,它能干净地扩展到语音、RCS 以及 Orchestrator 支持的任何其他渠道,无需任何定制持久化代码即可跨会话记住客户,并且在模型层保持可替换。

现在轮到你扩展它了。构建一个我们都会喜欢的体验,并在我们的 Subreddit 中分享——我们迫不及待想看看你会构建什么。

来源:Hacker News · twilio.com