1. 为什么多回合代理值得单独拿出来做
多回合 AI 代理这个概念,这两年被聊得很多,但真正落到代码层面,很多人第一反应还是“不就是把历史消息拼起来再发给模型吗”。如果你也这么想过,那说明你还没被真实项目毒打过。我最早做客服类代理的时候也是这个思路,结果上线第三天就翻车:用户问“我上周买的那个东西能退吗”,模型完全不知道“那个东西”指的是什么,因为它只拿到了当前这一轮的消息。后来我把整个对话历史塞进去,token 消耗直接爆炸,一个会话跑下来成本高得离谱,而且模型还会被前面几十轮的无关内容带偏。
多回合代理要解决的核心问题,其实就三件事:记住该记的、忘掉该忘的、在该调用工具的时候准确调用。听起来简单,做起来每一步都是坑。Genkit 这套代理 API 的价值就在于,它把这三件事拆成了可组合的抽象——会话状态、工具调用、流程编排,你不用自己从零搭一套状态机。
这篇文章适合谁看?如果你已经会用 TypeScript 写点后端逻辑,对 LLM 的基本调用不陌生,但一想到要把“能记住上下文、能调外部接口、能多轮追问”的代理真正落地就头大,那这篇就是写给你的。我会用 Genkit 的代理 API 从零搭一个多回合代理,中间会把 Firestore 做会话持久化、工具定义、回合控制这些环节全部拆开讲,代码可以直接抄。
先说清楚技术选型。Genkit 是 Google 开源的一套 AI 应用开发框架,TypeScript 优先,它的defineFlow、defineTool、generate这些原语组合起来,天然适合做代理编排。Firestore 作为会话存储,胜在实时同步和按文档粒度读写,一个会话一个文档,读写成本可控。这套组合我在两个内部项目里跑过,稳定性没问题。
2. 多回合代理的整体设计与核心思路拆解
2.1 单回合和多回合的本质区别在哪
很多人把多回合理解成“循环调用模型”,这个理解太浅了。单回合是输入 -> 模型 -> 输出,多回合是输入 -> 判断需不需要工具 -> 调用工具 -> 把工具结果喂回模型 -> 再判断 -> 输出,中间可能循环好几圈。关键区别在于控制权在谁手里。
单回合里,控制权完全在模型,它输出什么就是什么。多回合里,控制权要在你的编排逻辑和模型之间来回交接。模型说“我要调用查订单工具”,你的代码得真的去调,把结果拿回来再交给模型,模型才能继续。这个交接过程如果设计不好,就会出现死循环、工具调用参数错误、上下文丢失这些经典问题。
Genkit 的代理 API 在这里的作用,是把“模型决定调用工具”这件事变成结构化的输出。你定义工具的时候会给出 schema,模型返回的工具调用请求会被校验,不符合 schema 的直接被拦下来,不会带着错误参数去执行。这一点比裸调 API 强太多,裸调的时候模型给你返回个{"orderId": "abc"}但你的工具要的是数字,运行时才炸。
2.2 会话状态该存什么、不该存什么
这是多回合代理最容易做错的地方。我见过有人把整个对话历史原封不动存下来,每次全量发给模型,短期能用,长期必崩。正确的做法是分层存储:
- 原始消息历史:完整存,用于审计和回溯,但不全量发给模型
- 工作记忆:最近 N 轮的消息,N 根据你的场景定,一般 6 到 10 轮够用
- 摘要记忆:把更早的对话压缩成一段摘要,token 占用小
- 结构化状态:比如用户 ID、当前订单号、已确认的意图,这些用字段存,不靠模型从历史里猜
Firestore 的文档模型特别适合这个分层。一个会话文档长这样:
interface SessionDoc { sessionId: string; userId: string; createdAt: number; updatedAt: number; recentMessages: Message[]; // 最近 N 轮 summary: string; // 早期对话摘要 slots: Record<string, any>; // 结构化槽位 }slots这个字段是我踩坑之后加的。早期我全靠模型从历史里推断“用户想退的是哪个订单”,模型十次有两次推断错。后来我把订单号、商品 ID 这些关键信息在工具调用成功后直接写进slots,下一轮直接注入到系统提示里,准确率立刻上来了。能结构化的信息绝不交给模型去记,这是铁律。
2.3 为什么选 Genkit 而不是自己撸一套
自己撸一套编排逻辑不是不行,但你要处理的东西比想象中多:工具 schema 校验、流式输出、重试、超时、多模型切换、可观测性。Genkit 把这些都封装好了,你专注在业务逻辑上。它的defineTool会自动根据你给的 Zod schema 生成工具描述,模型看到的工具定义是标准化的,调用成功率明显更高。
另一个原因是 Genkit 的 flow 天然支持中间件式的拦截。你可以在 flow 的每一步插入日志、埋点、权限校验,这在做企业级代理的时候是刚需。我做过一个需要按用户角色限制工具访问的场景,就是在 flow 层拦截的,模型根本看不到它没权限调用的工具。
3. 核心细节解析与实操要点
3.1 环境准备和依赖安装
先把环境搭起来。Node 版本建议 20 以上,Genkit 对 ESM 支持比较好,所以package.json里记得加"type": "module"。
npm init -y npm install genkit @genkit-ai/googleai @genkit-ai/firebase zod npm install -D typescript tsx @types/node这里有个坑要提前说:@genkit-ai/firebase和@genkit-ai/googleai是两个包,前者管 Firestore 集成和部署,后者管模型调用。很多人只装了一个然后报错找不到插件,就是漏了。TypeScript 配置里moduleResolution要设成bundler或者node16,不然 Genkit 的类型导出会解析失败。
{ "compilerOptions": { "target": "ES2022", "module": "ES2022", "moduleResolution": "bundler", "strict": true, "esModuleInterop": true, "skipLibCheck": true } }skipLibCheck建议开着,Genkit 依赖链里有些第三方类型定义不完全干净,不开的话编译会报一堆跟你无关的错。
3.2 定义工具:schema 是成败关键
工具定义是整个代理的地基。我见过太多人工具描述写得含糊,模型调用成功率低,然后怪模型笨。其实问题出在 schema 和描述上。
import { z } from "zod"; import { defineTool } from "@genkit-ai/ai"; export const queryOrderTool = defineTool( { name: "queryOrder", description: "根据订单号查询订单的详细状态,包括物流、退款状态。当用户询问具体订单时使用。", inputSchema: z.object({ orderId: z.string().describe("订单号,通常是 12 位数字"), }), outputSchema: z.object({ status: z.string(), logistics: z.string(), refundable: z.boolean(), }), }, async (input) => { // 实际查询逻辑 const order = await db.collection("orders").doc(input.orderId).get(); if (!order.exists) { return { status: "not_found", logistics: "", refundable: false }; } const data = order.data()!; return { status: data.status, logistics: data.logistics, refundable: data.status === "delivered", }; } );几个要点。第一,description要写清楚什么时候用,不只是写这个工具干什么。模型判断要不要调用工具,靠的就是这句“当用户询问具体订单时使用”。第二,inputSchema里每个字段都要.describe(),模型生成参数时会参考这些描述。第三,输出 schema 要稳定,不要有时候返回字符串有时候返回对象,模型会被搞晕。
注意:工具名用驼峰,不要用下划线或连字符。部分模型对工具名的格式敏感,驼峰的成功率最高,这是实测出来的。
3.3 会话存储层:Firestore 的读写策略
Firestore 存会话,核心是控制读写频率。每个回合结束写一次,不要每来一条消息就写。读的时候只读当前会话文档,不要扫集合。
import { getFirestore } from "firebase-admin/firestore"; const db = getFirestore(); export async function loadSession(sessionId: string): Promise<SessionDoc | null> { const doc = await db.collection("sessions").doc(sessionId).get(); return doc.exists ? (doc.data() as SessionDoc) : null; } export async function saveSession(session: SessionDoc): Promise<void> { session.updatedAt = Date.now(); await db.collection("sessions").doc(session.sessionId).set(session, { merge: true }); }merge: true这个参数别漏。不加的话整个文档会被覆盖,如果你有并发写入(比如用户在两个设备上同时聊),后写的会把先写的字段冲掉。加了 merge 之后只更新你传的字段,安全很多。
还有一个细节:recentMessages数组不要无限增长。每次保存前做一次裁剪,只保留最近 N 轮。裁剪逻辑放在保存函数里,别散落在业务代码各处。
const MAX_RECENT = 12; // 6 轮对话 function trimMessages(messages: Message[]): Message[] { if (messages.length <= MAX_RECENT) return messages; return messages.slice(-MAX_RECENT); }3.4 上下文组装:把该给模型的东西给对
每一轮调用模型前,你要组装一个 prompt。这个 prompt 由四部分组成:系统提示、摘要、最近消息、当前输入。顺序很重要,系统提示在最前,摘要紧跟其后,然后是最近消息,最后是当前输入。
function buildPrompt(session: SessionDoc, userInput: string) { const systemParts = [ "你是一个订单客服代理。回答要简洁,涉及具体订单时必须先调用 queryOrder 工具。", ]; if (session.summary) { systemParts.push(`之前的对话摘要:${session.summary}`); } if (Object.keys(session.slots).length > 0) { systemParts.push(`已知信息:${JSON.stringify(session.slots)}`); } return { system: systemParts.join("\n"), messages: [ ...session.recentMessages, { role: "user", content: [{ text: userInput }] }, ], }; }把slots注入系统提示这一步,是我前面说的“能结构化就别让模型记”的落地。用户上一轮说了订单号,工具调用成功后我把订单号写进 slots,这一轮系统提示里直接告诉模型“已知订单号是 xxx”,模型就不用再从历史里翻,准确率和速度都上来了。
4. 实操过程与核心环节实现
4.1 用 defineFlow 编排整个回合
Genkit 的defineFlow是编排的核心。一个完整的回合流程是这样的:加载会话 -> 组装 prompt -> 调用模型 -> 判断是否有工具调用 -> 执行工具 -> 把结果喂回模型 -> 得到最终回复 -> 更新会话 -> 返回。
import { defineFlow } from "@genkit-ai/flow"; import { generate } from "@genkit-ai/ai"; import { gemini15Flash } from "@genkit-ai/googleai"; export const chatFlow = defineFlow( { name: "chatFlow", inputSchema: z.object({ sessionId: z.string(), message: z.string() }) }, async ({ sessionId, message }) => { let session = await loadSession(sessionId); if (!session) { session = createEmptySession(sessionId); } const prompt = buildPrompt(session, message); let response = await generate({ model: gemini15Flash, system: prompt.system, messages: prompt.messages, tools: [queryOrderTool], }); // 工具调用循环 let loopCount = 0; while (response.toolRequests.length > 0 && loopCount < 5) { loopCount++; const toolResults = []; for (const req of response.toolRequests) { const result = await executeTool(req); toolResults.push({ name: req.name, output: result }); } response = await generate({ model: gemini15Flash, system: prompt.system, messages: [ ...prompt.messages, response.message, ...toolResults.map(toToolMessage), ], tools: [queryOrderTool], }); } // 更新会话 session.recentMessages = trimMessages([ ...session.recentMessages, { role: "user", content: [{ text: message }] }, { role: "model", content: [{ text: response.text }] }, ]); await saveSession(session); return { reply: response.text }; } );loopCount < 5这个上限是必须的。我遇到过模型陷入工具调用死循环的情况,一直调同一个工具,因为工具返回的结果它不满意。没有上限的话这个请求会一直跑下去,烧钱还占资源。5 次是个经验值,大部分场景 2 到 3 次就够了。
4.2 工具执行的安全封装
工具执行不能直接调,要包一层。原因有三个:参数校验、错误处理、副作用记录。
async function executeTool(req: ToolRequest) { try { const tool = toolRegistry[req.name]; if (!tool) { return { error: `未知工具: ${req.name}` }; } const parsed = tool.inputSchema.parse(req.input); const result = await tool.run(parsed); return result; } catch (err) { return { error: `工具执行失败: ${(err as Error).message}` }; } }错误不要抛出去,要作为结果返回给模型。模型看到{"error": "订单不存在"}之后,会自己组织语言告诉用户,而不是整个流程崩掉。这是多回合代理健壮性的关键——任何工具失败都不应该让对话中断。
4.3 摘要生成:什么时候压缩历史
当recentMessages超过阈值,就要把最老的一部分压缩成摘要。这个操作不要每轮都做,太费 token。我的做法是当消息数超过 20 条时触发一次压缩,把前 10 条压成摘要,剩下的保留。
async function maybeSummarize(session: SessionDoc) { if (session.recentMessages.length < 20) return session; const toSummarize = session.recentMessages.slice(0, 10); const summaryResponse = await generate({ model: gemini15Flash, prompt: `把以下对话压缩成一段不超过 100 字的摘要,保留关键事实和用户意图:\n${JSON.stringify(toSummarize)}`, }); session.summary = summaryResponse.text; session.recentMessages = session.recentMessages.slice(10); return session; }摘要的 prompt 里一定要强调“保留关键事实”,不然模型会压得太狠,把订单号这种关键信息丢掉。我试过不强调,结果摘要里只剩“用户咨询了订单问题”,等于没压。
4.4 参数计算:token 预算怎么估
多回合代理的成本控制,核心是 token 预算。粗略估算:一轮对话的输入 token 约等于系统提示 + 摘要 + 最近消息 + 当前输入。中文大概 1 个字 1.5 个 token,英文 1 个词 1.3 个 token。
假设系统提示 200 token,摘要 150 token,最近 12 条消息平均每条 50 token 就是 600 token,当前输入 100 token,加起来输入约 1050 token。输出一般 200 token 以内。一轮下来 1250 token 左右。如果工具调用循环 2 次,输入会翻倍,因为每次都要把之前的消息重新发一遍。
所以控制成本的关键是控制循环次数和最近消息数量。最近消息从 12 条降到 8 条,输入能省 200 token。循环次数从 5 降到 3,最坏情况能省一半。这些参数要根据你的场景调,没有万能值。
5. 常见问题与排查技巧实录
5.1 模型不调用工具怎么办
这是最高频的问题。模型该调工具的时候不调,直接编一个答案给用户。排查顺序是这样的:
先看工具描述。描述里有没有明确说“什么时候用”?如果只写了“查询订单”,模型可能觉得它可以直接回答。改成“当用户询问具体订单状态、物流、退款时,必须先调用此工具”,成功率立刻不一样。
再看系统提示。系统提示里要明确要求“涉及具体订单必须先调用工具”。模型对系统提示的服从度比工具描述高。
最后看工具数量。如果你定义了十几个工具,模型选择困难,调用率会下降。工具超过 8 个的时候,考虑做工具分组,或者根据场景动态注入相关工具。
5.2 工具参数生成错误怎么修
模型生成的参数不符合 schema,常见的是类型错误和字段缺失。解决办法是在.describe()里把格式写死。比如订单号,不要只写“订单号”,要写“订单号,12 位纯数字字符串”。模型看到具体格式,生成错误的概率大幅下降。
如果还是错,可以在系统提示里加一个示例:“例如用户说‘查一下订单 123456789012’,你应该调用 queryOrder 并传入 orderId: '123456789012'”。给一个 few-shot 示例,效果立竿见影。
5.3 会话串号问题
这个坑很隐蔽。如果你的 sessionId 生成逻辑有问题,两个用户的会话可能串到一起。我见过用时间戳做 sessionId 的,同一毫秒内两个请求就撞了。正确做法是用crypto.randomUUID(),或者用用户 ID 加随机后缀。
import { randomUUID } from "crypto"; const sessionId = `${userId}_${randomUUID()}`;另外 Firestore 的文档路径也要注意,sessions/{sessionId}里的 sessionId 不能包含斜杠,否则会被当成子集合路径。UUID 里没有斜杠,安全。
5.4 常见问题速查表
| 问题现象 | 可能原因 | 排查方向 |
|---|---|---|
| 模型不调工具 | 工具描述含糊、系统提示未强调 | 改描述、加系统提示约束 |
| 参数生成错误 | schema 描述不具体 | 补充格式说明和示例 |
| 对话中断 | 工具抛异常未捕获 | 工具执行包 try-catch |
| 成本过高 | 历史消息过长、循环次数多 | 裁剪消息、限制循环 |
| 会话串号 | sessionId 冲突 | 用 UUID 生成 |
| 响应变慢 | 摘要未及时生成 | 检查压缩触发阈值 |
| 模型答非所问 | slots 未注入 | 检查系统提示组装 |
5.5 几个我踩过的坑
第一个坑:Firestore 的set和update混用。set不带 merge 会覆盖整个文档,update在文档不存在时会报错。统一用set加merge: true,省心。
第二个坑:工具返回的数据结构不稳定。有一次我的工具在订单不存在时返回null,存在时返回对象,模型处理null的时候直接懵了。后来统一成永远返回对象,不存在时返回{ status: "not_found" },问题解决。
第三个坑:并发写入。用户快速连发两条消息,两个请求同时读会话、同时写会话,后写的覆盖先写的。解决办法是加一个乐观锁,用updatedAt做版本校验,写入前检查版本号。或者简单点,在客户端做防抖,同一会话的请求串行发送。
第四个坑:模型把工具返回的原始 JSON 直接吐给用户。工具返回{"status": "delivered", "refundable": true},模型有时候会原样输出。解决办法是在系统提示里明确“不要向用户展示原始数据格式,用自然语言转述”。
6. 工具选型与扩展思路
6.1 模型选择:快模型和强模型怎么配
不是所有回合都需要强模型。我的做法是分两级:意图识别和简单问答用快模型,涉及工具调用和复杂推理用强模型。Genkit 支持在 flow 里动态切换模型,根据当前输入判断用哪个。
const model = needsReasoning(message) ? gemini15Pro : gemini15Flash;needsReasoning可以简单判断:消息里有没有订单号、有没有“为什么”“怎么办”这类词。有就用强模型,没有就用快模型。这样成本能降三到四成,体验几乎没差别。
6.2 可观测性:日志该记什么
代理出问题的时候,没有日志就是抓瞎。我建议每个回合记这几样:sessionId、输入消息、模型原始输出、工具调用记录、最终回复、耗时、token 消耗。Genkit 有内置的 trace 能力,但生产环境我建议自己再落一份到 Firestore 或者日志服务,方便按 sessionId 检索。
await db.collection("traces").add({ sessionId, input: message, toolCalls: toolCallLog, output: response.text, latencyMs: Date.now() - startTime, timestamp: Date.now(), });toolCalls这个字段特别有用。用户投诉“代理答错了”的时候,你一看 trace 就知道是模型没调工具,还是工具返回了错误数据,还是模型转述错了。定位问题的速度完全不一样。
6.3 后续可以怎么扩展
这套骨架搭好之后,扩展方向很多。加多模态,让用户能发图片,Genkit 的 generate 支持图片输入,工具里也能处理图片。加人工接管,当模型置信度低或者用户明确要求转人工时,把会话标记出来推给人工客服。加 A/B 测试,同一个 flow 挂两个不同版本的提示词,按 sessionId 分流,对比效果。
我个人最推荐的扩展方向是工具的组合。单个工具能力有限,但你可以定义一些“复合工具”,内部调用多个原子工具再聚合结果。比如“查订单并判断能否退款”这个复合工具,内部先查订单再查退款政策,模型只需要调一次,效率和准确率都更高。
最后分享一个我在实际项目里验证过的小技巧:把用户的历史高频问题整理成一个 FAQ 工具,让模型在回答前先查 FAQ。很多问题其实 FAQ 里已经有标准答案,模型直接引用比它自己组织语言更准确,也更快。这个工具我加进去之后,简单问题的响应时间从 3 秒降到了 1 秒出头,用户体感提升非常明显。