多回合代理这件事真正上手做过的人都知道难点从来不在让模型回一句话而在于怎么让它在多轮交互里记住上下文、决定下一步动作、调用工具、再把结果整合成用户能看懂的回复。我最近用 Genkit 的代理 API 搭了一套多回合 AI 代理从环境准备到工具编排再到状态持久化中间踩了不少坑也积累了一些文档里不会写的经验。这篇就把整个搭建过程拆开讲清楚包括为什么这么设计、每一步的意图是什么、哪些地方容易翻车。Genkit 是 Google 开源的一套 AI 应用开发框架核心卖点是用 TypeScript 把模型调用、工具、流程编排统一起来而它的代理 APIAgent API专门用来处理多回合对话场景。如果你正在做客服机器人、任务型助手、或者任何需要记住上一轮说了什么、并根据历史决定下一步的应用这套东西值得认真研究。下面我按实际搭建顺序展开代码以 TypeScript 为主状态存储用 Firestore。1. 先搞清楚多回合代理到底难在哪1.1 单轮调用和多回合代理的本质区别很多人第一次接触 AI 代理脑子里想的还是输入一句话输出一句话。这种单轮模式确实简单一次 API 调用就完事。但多回合代理完全是另一回事它要解决三个单轮场景根本不存在的问题。第一个是上下文累积。用户第一轮说帮我查下北京明天的天气第二轮说那后天呢这里的后天必须结合上一轮才能理解。如果每轮都当成独立请求发给模型模型根本不知道后天指的是什么。所以代理必须维护一个对话历史并且每轮都把相关历史喂给模型。第二个是决策与工具调用。多回合代理往往不是单纯聊天它需要判断这一轮我该直接回答还是该调用某个工具。比如用户问天气代理得决定去调天气 API用户问你刚才说的那个城市代理得从历史里提取城市名而不是再调一次工具。这个决定下一步做什么的过程就是代理的核心。第三个是状态管理。多回合意味着状态要跨请求存活。用户可能隔了十分钟才回第二句这中间服务可能重启过、可能换了实例状态不能丢。这就引出持久化的问题也是后面要重点讲的 Firestore 部分。1.2 为什么选 Genkit 而不是自己拼自己拼一套多回合代理完全可行无非是维护 messages 数组、写个循环调模型、手动解析工具调用。但真做起来你会发现重复劳动特别多工具调用的参数校验、多轮循环的终止条件、流式输出的拼接、错误重试……每一样都要自己写。Genkit 的代理 API 把这些抽象掉了。它提供了一套声明式的工具定义方式你只要描述这个工具叫什么、接收什么参数、干什么事框架会自动处理模型和工具之间的往返。它还内置了多回合循环的控制逻辑模型说要调工具框架就执行工具、把结果塞回对话、再让模型继续直到模型给出最终回复。这套机制省下来的代码量相当可观。更关键的是Genkit 和 TypeScript 的类型系统结合得很好。工具的参数用 Zod schema 定义模型返回的工具调用会被自动校验和类型推断写起来有类型提示出错也容易定位。对于习惯了 TypeScript 严格模式的团队这点体验提升很明显。1.3 一个典型的多回合场景长什么样举个具体例子假设我们做一个差旅助手代理。用户说我下周要去上海出差代理回复好的需要我帮你订机票和酒店吗。用户说先订机票周二早上出发代理这时候要调用航班查询工具拿到结果后问有几个航班你偏好哪个时间段。用户说上午十点左右的代理再调订票工具完成预订。这个流程里代理至少经历了四轮交互中间调用了两次工具而且第二轮的理解依赖第一轮的城市信息。这就是多回合代理的典型形态对话历史 工具调用 状态累积三者交织。理解了这一点后面的技术选型和代码设计就有了方向。2. 环境搭建与 Genkit 项目初始化2.1 依赖安装与版本选择Genkit 的包更新比较快我建议锁定一个稳定版本再开工避免中途因为小版本升级导致 API 变化。核心依赖是genkit主包和对应的模型插件比如用 Google 的模型就装genkit-ai/googleai用 Firestore 做存储就装genkit-ai/firebase。npm install genkit genkit-ai/googleai genkit-ai/firebase npm install -D typescript tsx types/node这里有个容易忽略的点Genkit 对 Node 版本有要求我实测下来 Node 20 以上最稳Node 18 在某些插件上会有兼容问题。另外 TypeScript 建议开strict模式因为 Genkit 的类型推断在严格模式下才能发挥最大价值工具参数的类型错误能在编译期就暴露出来。2.2 初始化 Genkit 实例与模型配置Genkit 的核心是一个全局实例所有流程、工具、代理都挂在这个实例上。初始化的时候要指定模型插件和默认模型。import { genkit } from genkit; import { googleAI } from genkit-ai/googleai; export const ai genkit({ plugins: [googleAI()], model: googleai/gemini-1.5-flash, });模型选择上多回合代理对模型的指令遵循能力要求比单轮高。因为模型不仅要回答问题还要正确判断何时调工具、如何组织工具参数。我试过几个模型flash 系列在速度和成本上平衡得不错适合多轮高频调用如果任务复杂、工具多可以换更强的模型但延迟和成本会上去。这个取舍要根据你的实际场景定。提示模型名称里的 provider 前缀如googleai/不能省Genkit 靠这个前缀路由到对应插件。我一开始漏了前缀报错信息很含糊排查了半天。2.3 目录结构建议多回合代理的代码量会随着工具数量增长目录结构一开始就要规划好否则后期一团乱。我用的结构是这样的src/ ai.ts # Genkit 实例初始化 tools/ # 所有工具定义 weather.ts flight.ts agents/ # 代理定义 travelAgent.ts flows/ # 对外暴露的流程 chat.ts store/ # 状态持久化 sessionStore.ts把工具、代理、流程、存储分开好处是每个文件职责单一测试和替换都方便。尤其是工具随着业务扩展会越来越多集中放在tools/下便于管理。3. 用代理 API 定义工具与多回合循环3.1 工具定义Zod schema 是核心Genkit 里定义工具用ai.defineTool关键是参数 schema 用 Zod 写。这个 schema 不只是校验它还决定了模型看到的工具描述直接影响模型能不能正确调用。import { z } from genkit; import { ai } from ../ai; export const getWeather ai.defineTool( { name: getWeather, description: 查询指定城市指定日期的天气情况, inputSchema: z.object({ city: z.string().describe(城市名称如北京), date: z.string().describe(日期格式 YYYY-MM-DD), }), outputSchema: z.object({ condition: z.string(), temperature: z.number(), }), }, async (input) { // 实际调用天气 API return { condition: 晴, temperature: 22 }; } );这里有几个经验点。第一description要写得像给同事解释一样清楚模型就是靠这个判断什么时候用这个工具。第二inputSchema里每个字段的.describe()别省模型生成参数时会参考这些描述写清楚了能显著降低参数错误率。第三outputSchema定义了工具返回的结构模型拿到的是结构化数据比返回一坨字符串好处理得多。3.2 多回合循环是怎么跑起来的Genkit 代理 API 的核心是ai.generate配合工具列表。当你把工具传给 generate框架会自动进入模型调用 → 检查是否有工具调用 → 执行工具 → 把结果回传模型 → 再调用模型的循环直到模型不再请求工具、直接给出文本回复。const response await ai.generate({ prompt: userInput, tools: [getWeather, searchFlight], messages: history, // 历史对话 });这个循环的终止条件是模型不再返回工具调用请求。理解这一点很重要因为它意味着你不需要自己写 while 循环框架帮你处理了。但反过来说如果模型陷入反复调同一个工具的循环你需要在工具层面做防护比如限制单个工具在单轮对话里的调用次数。3.3 对话历史怎么传多回合的关键是messages参数。每一轮结束后你要把用户输入和模型回复都追加到历史里下一轮再传进去。Genkit 的 message 结构包含 roleuser/model和 content。history.push({ role: user, content: [{ text: userInput }] }); history.push({ role: model, content: response.message.content });这里有个坑历史不能无限增长。对话轮次多了以后token 消耗会爆炸而且模型对超长上下文的注意力会下降。我的做法是保留最近 N 轮完整历史更早的做摘要压缩。摘要可以用模型生成把前面对话浓缩成几句话既保留关键信息又控制长度。注意工具调用的中间结果要不要放进历史是个需要权衡的问题。放进去模型能看到完整的推理链但会占用大量 token不放的话模型可能忘记自己调过什么工具。我的经验是工具的关键结果比如查到的航班号保留冗长的原始返回做精简。4. 用 Firestore 做会话状态持久化4.1 为什么状态不能只放内存开发阶段把对话历史放在内存的 Map 里跑起来没问题。但一上线就出问题服务多实例部署时用户第一轮请求打到实例 A第二轮打到实例 B实例 B 的内存里没有历史代理就失忆了。就算单实例服务重启也会丢状态。所以多回合代理的状态必须外部化存储。Firestore 是个不错的选择它是文档型数据库天然适合存会话这种结构而且有实时同步能力后面要做多端同步也方便。4.2 会话数据结构设计Firestore 里我用一个sessions集合每个文档对应一个会话文档 ID 就是 sessionId。interface SessionDoc { sessionId: string; userId: string; history: Array{ role: string; content: any[] }; createdAt: number; updatedAt: number; metadata: Recordstring, any; }history存对话历史metadata可以放一些业务相关的状态比如用户当前正在订票流程中这种标记。updatedAt用来做清理超过一定时间没活动的会话可以归档或删除控制存储成本。4.3 读写会话的封装把 Firestore 的读写封装成sessionStore代理逻辑里只调这几个方法不直接碰数据库。import { getFirestore } from firebase-admin/firestore; const db getFirestore(); export async function loadSession(sessionId: string) { const doc await db.collection(sessions).doc(sessionId).get(); return doc.exists ? (doc.data() as SessionDoc) : null; } export async function saveSession(session: SessionDoc) { session.updatedAt Date.now(); await db.collection(sessions).doc(session.sessionId).set(session); }这里有个并发问题要注意如果同一个会话的两个请求几乎同时到达可能出现读-改-写覆盖。Firestore 支持事务对状态敏感的写操作建议用事务包起来保证读到的历史是最新的。4.4 状态加载与保存的时机在流程入口处加载会话处理完一轮后保存。注意保存要放在模型调用和工具执行都完成之后确保历史是完整的。export const chatFlow ai.defineFlow( { name: chatFlow, inputSchema: z.object({ sessionId: z.string(), message: z.string() }) }, async ({ sessionId, message }) { let session await loadSession(sessionId); if (!session) { session { sessionId, userId: , history: [], createdAt: Date.now(), updatedAt: Date.now(), metadata: {} }; } session.history.push({ role: user, content: [{ text: message }] }); const response await ai.generate({ prompt: message, tools: [getWeather], messages: session.history }); session.history.push({ role: model, content: response.message.content }); await saveSession(session); return response.text; } );5. 实测中踩过的坑与排查过程5.1 工具参数类型不匹配导致静默失败第一次跑通工具调用时我发现模型有时候传的日期格式是明天而不是2024-06-01Zod 校验直接失败但错误信息被吞掉了代理只是回复抱歉我无法处理。排查过程是这样的先在工具函数入口打日志确认工具根本没被调用然后打开 Genkit 的调试模式看到模型返回的工具调用参数确实不符合 schema。根因是工具描述里虽然写了格式要求但模型不一定严格遵守。解决办法是在inputSchema的.describe()里把格式要求写得更强硬同时在工具函数里做一层容错比如把明天这种相对日期解析成绝对日期。这个坑的教训是不要假设模型会严格遵守 schema 描述关键参数要在代码里兜底。5.2 多轮对话里模型重复调用工具有个场景是用户问北京天气怎么样代理调了天气工具。用户接着问那湿度呢代理又调了一次天气工具但这次参数里的城市丢了变成空字符串。排查发现是历史里工具调用的结果没有被正确保留模型看不到上一轮查的是北京。修复方式是在保存历史时把工具调用的结果也结构化地存进去让模型在下一轮能看到上一轮我查了北京。Genkit 的 message 结构支持工具调用和工具结果作为独立的消息类型用对了就能解决。5.3 Firestore 写入延迟导致的历史丢失测试时偶尔出现用户第二轮提问代理却像第一次对话一样。查日志发现是保存会话的写操作还没完成下一个请求就读了旧数据。Firestore 的写是异步的虽然通常很快但高并发下会有延迟。解决办法有两个一是写操作 await 完成后再返回响应保证用户收到回复时状态已落库二是对同一会话的请求做串行化避免并发读写。我选了第一种简单可靠代价是响应稍微慢一点点。5.4 长对话的 token 超限跑到十几轮以后请求开始报 token 超限。原因是历史全量传给了模型。解决办法前面提过做历史压缩保留最近 5 轮完整对话更早的用模型生成摘要。摘要本身也要控制长度我限制在 200 字以内。实测下来压缩后能支撑几十轮对话不超限。6. 让代理更稳的几个工程化技巧6.1 工具调用的幂等性设计多回合代理里工具可能因为重试被调用多次。如果工具是查天气这种只读操作无所谓但如果是下单扣款这种写操作重复调用就是灾难。所以写操作类工具一定要做幂等比如传入一个唯一的 requestId服务端根据 requestId 去重。6.2 给代理加思考边界模型有时候会过度调用工具明明可以直接回答的问题也要查一遍。可以在系统提示里明确告诉它简单问题直接回答不要调用工具减少不必要的工具调用既省成本又降延迟。6.3 错误兜底与用户可感知的反馈工具执行失败时不要让代理直接崩掉或返回技术错误。我的做法是工具内部捕获异常返回一个结构化的错误结果代理拿到后转成用户能理解的话比如查询天气的服务暂时不可用请稍后再试。这样用户体验不会断。6.4 可观测性日志与追踪多回合代理的调试比单轮难得多因为一次用户请求背后可能有多次模型调用和工具调用。Genkit 内置了追踪能力可以把每次调用的输入输出记录下来。生产环境建议接入日志系统按 sessionId 串联一次完整对话的所有调用出问题时能快速定位是哪一轮、哪个工具出的错。7. 从能跑到好用还差什么把上面这套搭完代理基本能跑通多回合对话和工具调用了。但从能跑到好用还有几件事值得投入。第一是提示词的持续打磨。代理的行为很大程度上由系统提示决定什么情况下调工具、怎么组织回复、遇到歧义怎么追问都要在提示里写清楚。这个没有一劳永逸的写法得根据实际对话日志不断调整。第二是工具粒度的把握。工具太粗模型不好组合工具太细模型要调很多次。我的经验是按用户能理解的一个动作来切分工具比如查航班和订航班分开而不是合成一个处理航班。第三是评估机制。多回合代理的效果很难靠人工一条条看建议建一套测试用例覆盖典型场景和边界情况每次改提示或改工具后跑一遍看通过率有没有下降。Genkit 本身也提供了一些评估工具可以结合使用。最后分享一个我自己的体会多回合代理的复杂度主要来自状态和决策这两块Genkit 的代理 API 帮你把决策循环和工具编排的框架搭好了但状态怎么存、历史怎么压缩、工具怎么设计这些还是得根据业务自己拿捏。框架能省掉重复劳动但省不掉对场景的思考。真正决定代理好不好用的往往不是用了什么框架而是你有没有把用户的实际对话路径想清楚。