多回合对话代理这件事,我在去年接手一个智能客服改造项目时被狠狠教育过一次。当时团队用最朴素的方式——把历史消息拼成一个超长 prompt 丢给模型——结果对话到第七八轮就开始"失忆",用户前面说过的订单号、偏好、已确认的方案,模型要么记混要么直接编。后来我把整套链路迁到 Genkit 的代理 API 上,才算真正把"多回合"这三个字落地。这篇就围绕 Genkit 代理 API 构建多回合 AI 代理这条主线,把我在 TypeScript 技术栈下踩过的坑、验证过的结构、以及 Firestore 做会话持久化的具体做法,完整摊开讲一遍。
如果你正在用 TypeScript 做 AI 应用,尤其是需要"记住上下文、能调用工具、能多轮追问"的代理型场景,这篇内容基本可以当成一份可复现的施工图。我会从为什么普通对话链撑不住多回合讲起,再拆 Genkit 代理 API 的核心抽象,接着给出会话状态管理、工具调用、Firestore 落库的完整实现,最后重点讲那些文档里不会写、但一上生产就爆的问题。
1. 为什么拼接历史消息的做法撑不过第五轮
1.1 一个真实翻车现场:订单号在第七轮"变异"了
先还原那个客服项目的现场。用户第一轮说"我要查订单 88231 的物流",第二轮补充"是上周三下的单",第三轮问"能不能改地址",第四轮给了新地址,第五轮确认"就改成这个",第六轮问"大概几天到",第七轮突然问"那我原来那个地址的订单呢"。
朴素方案是把这七轮全部拼成一个 messages 数组发给模型。问题出在第七轮:模型需要区分"原来那个地址"指的是第一轮订单的原始地址,还是第四轮之前的状态。当上下文超过一定长度,模型对早期信息的注意力被稀释,它开始把 88231 和另一个订单号混淆,甚至把用户给的新地址安到了错误的订单上。
这不是模型"笨",而是拼接式上下文本身没有结构。所有信息平铺在一个线性序列里,没有"这是实体""这是状态变更""这是已确认事实"的区分。轮次一多,信噪比急剧下降。
1.2 上下文窗口不是万能解药
很多人的第一反应是"那就换更大上下文的模型"。我试过,把窗口从 8K 拉到 128K,短期确实缓解了,但带来三个新问题。
第一是成本。多回合对话每轮都要把全部历史重新送进去,token 消耗随轮次线性甚至超线性增长。一个十轮对话,如果每轮平均 500 token,到第十轮单次请求就是 5000 token 的输入,成本是首轮的十倍。
第二是延迟。输入越长,首 token 延迟越明显,用户能感知到"越聊越卡"。
第三,也是最致命的,大窗口并不能保证模型真的"用对"早期信息。注意力机制对长序列中段信息的召回率天然偏低,这就是所谓的"lost in the middle"现象。窗口大了,但关键信息可能正好落在被忽略的中段。
1.3 多回合的本质是状态管理问题
想通这一点很关键:多回合对话不是"把话说全"的问题,而是"状态怎么存、怎么取、怎么更新"的问题。它和传统后端的会话管理没有本质区别——你需要一个会话对象,里面有稳定的实体槽位(订单号、地址、用户偏好),有状态变更记录,有当前意图。
Genkit 的代理 API 之所以适合这个场景,就是因为它把"代理"抽象成了一个有状态、可调用工具、可多步推理的执行单元,而不是一次性的文本生成。下面进入正题。
2. Genkit 代理 API 的核心抽象拆解
2.1 从 generate 到 agent:一次范式转换
如果你用过 Genkit 的基础 generate 接口,会发现它本质是"输入 prompt,输出文本"。而代理 API 引入的是"输入目标 + 上下文,输出动作序列或最终答复"。这个差别类似函数调用和状态机的差别。
代理 API 的核心构件我归纳为四块:
- Agent 定义:声明这个代理的身份、可用工具、系统指令。
- Session(会话):承载多回合状态的容器,跨请求存活。
- Tool(工具):代理可以调用的外部能力,比如查订单、改地址。
- Flow(流程):把代理、工具、持久化串起来的编排层。
这四块里,Session 是多回合的命脉。没有它,代理每轮都是"失忆的新人"。
2.2 Agent 与 Tool 的职责边界怎么划
我见过不少团队把业务逻辑全塞进系统指令里,比如"如果用户问订单就查数据库"。这是反模式。正确做法是把"能做什么"交给 Tool,"怎么决策"交给 Agent。
举个例子,查订单这个能力应该是一个 Tool:
import { genkit, z } from 'genkit'; import { googleAI } from '@genkit-ai/googleai'; const ai = genkit({ plugins: [googleAI()] }); const queryOrder = ai.defineTool( { name: 'queryOrder', description: '根据订单号查询订单的当前状态、物流和收货地址', inputSchema: z.object({ orderId: z.string().describe('订单号') }), outputSchema: z.object({ status: z.string(), address: z.string(), logistics: z.string(), }), }, async ({ orderId }) => { // 真实项目里这里查数据库 return { status: '已发货', address: '北京市朝阳区...', logistics: '运输中' }; } );Tool 的 description 极其重要,它是模型决定"要不要调用、调用哪个"的唯一依据。我踩过的坑是 description 写得太笼统,比如"查询订单信息",结果模型在用户只是闲聊提到"订单"两个字时也去调用,浪费一次往返。后来我把 description 改成明确的触发条件描述,误调用率明显下降。
2.3 系统指令要写"决策规则"而不是"业务知识"
Agent 的系统指令应该聚焦在决策逻辑上,比如"当用户提供了订单号且询问状态时,调用 queryOrder;当用户要求修改地址时,先调用 queryOrder 确认订单存在,再调用 updateAddress"。业务知识(比如订单状态有哪些枚举值)应该放在 Tool 的 schema 和返回值里,让模型从工具结果中获取,而不是硬编码进指令。
这样做的好处是,业务规则变更时你只改 Tool,不用动指令,降低了指令膨胀和模型困惑的风险。
3. 用 Session 把多回合状态真正管起来
3.1 Session 里到底该存什么
这是整个项目最需要想清楚的地方。我的经验是把 Session 分成三层:
| 层级 | 内容 | 生命周期 | 存储位置 |
|---|---|---|---|
| 对话历史 | 原始消息序列 | 整个会话 | Firestore |
| 实体槽位 | 订单号、地址、偏好等结构化字段 | 整个会话,可更新 | Firestore |
| 临时推理 | 当前轮的中间思考 | 单轮 | 内存 |
对话历史用于给模型提供语境,实体槽位用于保证关键信息不丢失,临时推理用完即弃。很多人只存了对话历史,结果模型每轮都要从历史里"重新提取"订单号,既慢又不稳。把订单号显式存成槽位,每轮直接注入,稳定性天差地别。
3.2 槽位提取用工具而不是正则
怎么把用户说的"88231"变成槽位里的 orderId?早期我用正则匹配数字,结果用户说"订单八八二三一"就挂了。后来改成让代理在每轮结束时调用一个 extractSlots 工具,由模型判断当前轮是否产生了新的槽位值。
const extractSlots = ai.defineTool( { name: 'extractSlots', description: '从用户最新消息中提取结构化槽位,如订单号、地址、时间', inputSchema: z.object({ orderId: z.string().optional(), address: z.string().optional(), }), outputSchema: z.object({ updated: z.boolean() }), }, async (slots) => { // 合并进 session 的槽位层 return { updated: true }; } );用模型做提取的好处是能处理口语化表达,代价是多一次调用。我的取舍是:关键槽位(订单号、金额)用模型提取 + 格式校验双保险,非关键槽位(比如"大概""可能"这类模糊词)直接忽略。
3.3 历史消息的裁剪策略
Session 里的对话历史不能无限增长。我用的策略是"滑动窗口 + 摘要锚点":保留最近 N 轮完整消息,更早的消息压缩成一段摘要,摘要里只保留已确认的事实和未完成的意图。
具体做法是当历史超过 12 轮时,触发一次摘要生成,把前 6 轮压成一段 200 字以内的事实陈述,替换掉原始消息。这样既控制了 token,又保住了关键事实。实测下来,20 轮以上的长对话,token 消耗能压到不裁剪方案的三分之一左右。
4. Firestore 做会话持久化的落地细节
4.1 集合结构设计
Firestore 的文档模型很适合会话存储。我用的结构是:
sessions/{sessionId} - userId: string - createdAt: timestamp - updatedAt: timestamp - slots: map // 实体槽位 - summary: string // 历史摘要 - turnCount: number sessions/{sessionId}/messages/{messageId} - role: 'user' | 'model' | 'tool' - content: string - toolCalls: array - timestamp: timestamp把消息放在子集合而不是塞进主文档,是因为 Firestore 单文档有 1MB 上限,长对话很容易撑爆。子集合可以无限扩展,查询时按 timestamp 排序取最近 N 条即可。
4.2 读写时机与并发问题
写时机有两个:用户消息落库在代理执行前,模型回复落库在代理执行后。读时机是每轮开始时读取 slots 和最近消息。
这里有个容易忽略的并发坑:用户快速连发两条消息时,两个请求可能同时读到旧的 slots,各自更新后互相覆盖。我的解法是用 Firestore 事务包住"读 slots - 合并 - 写 slots"这段逻辑,保证原子性。
import { getFirestore, FieldValue } from 'firebase-admin/firestore'; const db = getFirestore(); async function mergeSlots(sessionId: string, newSlots: Record<string, string>) { const ref = db.collection('sessions').doc(sessionId); await db.runTransaction(async (tx) => { const snap = await tx.get(ref); const current = snap.data()?.slots ?? {}; tx.update(ref, { slots: { ...current, ...newSlots }, updatedAt: FieldValue.serverTimestamp(), }); }); }4.3 冷热数据分离控制成本
Firestore 的读操作是按文档计费的。如果每轮都全量读历史消息,长对话成本会很难看。我的做法是热数据(最近 6 轮消息 + slots + summary)常驻读取,冷数据(更早的原始消息)只在需要回溯时才查。配合前面说的摘要策略,绝大多数轮次只需要读 1 个主文档 + 6 条消息,成本可控。
5. 工具调用链路里那些一上生产就爆的问题
5.1 工具返回结果过大导致上下文爆炸
我遇到过一次事故:queryOrder 工具返回了完整的物流轨迹,包含 30 多条节点,每条还有时间、地点、操作人。一次调用就往上下文里塞了两千多 token,几轮下来直接把窗口撑满。
解法是工具返回"模型需要的最小信息集",而不是"数据库里有什么就返回什么"。物流轨迹只返回最新一条 + 是否异常,详细轨迹让用户点链接看。工具的输出 schema 要刻意设计得精简,这是很多人忽略的一环。
5.2 工具调用死循环
代理有时候会陷入"调用工具 - 结果不满意 - 再调用同一个工具"的循环。我见过最夸张的一次,代理连续调了 8 次 queryOrder,因为每次它都觉得返回的地址不是用户想要的。
防护手段有两个:一是给代理设置最大工具调用轮数(比如 5 轮),超过就强制生成回复;二是在工具返回里加入明确的"终止信号",比如返回{ found: false, hint: '订单不存在,请告知用户核对订单号' },引导模型停止重试。
5.3 工具报错怎么优雅降级
工具调用失败(数据库超时、第三方接口 500)时,如果直接把错误抛给模型,模型往往会编一个"查询成功"的假结果。正确做法是捕获错误,返回结构化的失败信息,并在系统指令里明确"工具返回 error 字段时,如实告知用户并建议稍后重试"。
async ({ orderId }) => { try { const order = await db.getOrder(orderId); if (!order) return { found: false, hint: '订单不存在' }; return { found: true, status: order.status, address: order.address }; } catch (e) { return { found: false, error: true, hint: '查询服务暂时不可用' }; } }6. 多回合代理的测试与可观测性
6.1 用固定剧本做回归测试
多回合代理最难测的是"状态在轮次间的传递"。我的做法是维护一批固定剧本,每个剧本是一串用户消息,断言最终槽位和关键回复。比如剧本"改地址":用户给订单号 → 问能否改地址 → 给新地址 → 确认,断言最后 slots.address 等于新地址。
这类测试跑在 CI 里,每次改指令或工具都跑一遍,能挡住大部分回归。注意断言不要写死模型的原话,而是断言结构化结果(槽位、工具调用序列),否则模型措辞一变测试就红。
6.2 记录每轮的工具调用轨迹
可观测性方面,我在每轮结束时把"用户消息、模型决策、工具调用及参数、工具返回、最终回复"整条链路写进一条日志。出问题时能完整回放某一轮发生了什么。Genkit 本身有 tracing 能力,但我会额外落一份业务日志到 Firestore,方便按 sessionId 检索。
6.3 用真实长对话做压力验证
回归测试用的是短剧本,但生产里会出现 30 轮以上的长对话。我专门构造了几条超长对话做压力测试,重点看三件事:槽位是否在 20 轮后仍然准确、摘要是否丢失关键事实、token 消耗是否随轮次失控。这三项过了,基本可以放心上线。
7. 我在这个项目里沉淀的几条硬经验
第一条,槽位优先于历史。能结构化的信息一定抽成槽位,不要指望模型每轮从历史里重新理解。这是多回合稳定性的地基。
第二条,工具的输出 schema 要当成 API 契约来设计,精简、明确、带终止信号。工具返回什么,直接决定模型下一轮怎么想。
第三条,摘要策略要早做。别等上下文爆了才想起来压缩,从第一版就把滑动窗口和摘要锚点设计进去,后期改造成本极高。
第四条,并发写一定要用事务。多回合场景下用户连发消息是常态,不用事务迟早遇到槽位覆盖。
第五条,测试断言结构化结果而非自然语言。模型措辞不可控,但槽位和工具调用序列是可控的,把测试建立在这上面才稳。
这套结构我在两个项目里复用下来,多回合对话的"失忆率"从最初的每十轮出一次问题,降到了基本可以忽略。Genkit 代理 API 提供的抽象不算复杂,真正花时间的是把 Session、槽位、工具、持久化这四件事的边界理清楚。理清之后,剩下的就是按部就班地填实现。