1. 从单次问答到多回合代理:为什么需要Genkit的代理API
很多人做AI应用的第一步,都是接一个大模型接口,然后写一个chat函数,把用户输入丢进去,拿回结果渲染到页面上。这个模式在演示阶段没问题,但一旦你想做点真正有用的东西——比如一个能帮用户查订单、改地址、退换货的客服助手,或者一个能根据用户需求逐步推荐旅行方案的规划工具——单次问答立刻就不够用了。
问题出在“状态”上。单次问答是无状态的,每次请求都是独立的,模型不知道上一轮用户说了什么,也不知道自己刚才承诺了什么。你当然可以把历史消息拼成一个长字符串再发给模型,但这样做有几个硬伤:第一,上下文窗口很快会被撑爆;第二,模型无法主动调用外部工具去获取实时数据;第三,你没法控制对话的走向,模型可能在第3轮就忘了第1轮用户提到的关键约束。
多回合AI代理要解决的就是这些问题。它需要具备三个核心能力:记住对话历史、在合适的时候调用外部工具、根据工具返回的结果继续推理并生成回复。Genkit的代理API就是为这个场景设计的。它提供了一套结构化的方式来定义代理的行为、管理对话状态、注册工具函数,并且把整个流程串起来。
我这次用Genkit的代理API配合TypeScript和Firestore,搭了一个多回合的客服代理原型。选TypeScript是因为Genkit对TS的支持最完整,类型推导能帮你在写工具函数时少踩很多坑;选Firestore是因为它天然适合存对话历史,而且和Genkit的会话管理能对上。下面我把整个搭建过程拆开讲,包括设计思路、关键代码、踩过的坑,以及一些文档里不会写的实操细节。
这篇文章适合两类人看:一是已经用过Genkit做单次生成、想进一步做多回合代理的开发者;二是正在选型多回合对话框架、想看看Genkit的代理API到底好不好用的人。如果你还没接触过Genkit,建议先花半小时跑通它的generate基础示例,再来看这篇,否则有些概念会有点跳。
2. 整体架构设计与核心思路拆解
2.1 为什么选Genkit的代理API而不是自己拼Prompt
自己拼Prompt做多回合对话,本质上是在做一件事:把历史消息序列化成文本,塞进下一次请求的上下文里。这个方案在对话轮次少、工具调用简单的时候能跑,但一旦轮次超过10轮,或者需要模型在多个工具之间做选择,维护成本会指数级上升。
Genkit的代理API把这件事抽象成了几个明确的组件。Agent定义代理的身份和行为,Session管理对话状态,Tool注册可调用的外部函数,Flow把整个执行链路串起来。你不需要手动拼历史消息,Genkit会在每次调用时自动把当前会话的上下文注入进去。你也不需要写复杂的路由逻辑来决定什么时候调工具,模型会根据工具的description和参数schema自己判断。
我选这个方案的核心原因是:它把“状态管理”和“工具调用”这两件最容易写乱的事,变成了框架层面的约定。你只需要关注业务逻辑——用户想干什么、需要查什么数据、返回什么格式——剩下的交给Genkit。
2.2 Firestore在架构中的角色与数据模型设计
Firestore在这个架构里承担的是会话持久化的角色。Genkit本身有内存级的会话存储,但进程重启就没了,不适合生产环境。Firestore的好处是:写入延迟低、支持实时监听、按文档组织数据天然适合会话这种结构。
我设计的Firestore数据模型是这样的:
sessions集合:每个文档对应一个会话,文档ID就是sessionIduserId:用户标识createdAt:会话创建时间lastActiveAt:最后活跃时间status:会话状态(active / closed)
sessions/{sessionId}/messages子集合:每条消息一个文档role:user / assistant / toolcontent:消息内容toolCalls:如果是工具调用消息,记录调用的工具名和参数timestamp:消息时间
这个模型的好处是:消息按时间顺序排列,读取时直接按timestamp排序就行;子集合的结构让单个会话的消息不会影响其他会话的查询性能;lastActiveAt可以用来做会话过期清理。
注意:Firestore的子集合查询需要建立索引,如果你要按
role过滤消息,记得在Firestore控制台里给messages子集合加一个role+timestamp的复合索引,否则查询会报错。
2.3 多回合代理的状态流转逻辑
多回合代理的状态流转,核心是回答三个问题:当前会话处于什么阶段?用户这句话触发了什么意图?代理下一步该做什么?
我的设计是把状态分成两层。会话层状态存在Firestore里,包括对话历史、用户信息、当前任务上下文。执行层状态由Genkit的Agent在单次调用中管理,包括当前这轮需要调用哪些工具、工具返回后如何继续推理。
流转逻辑是这样的:用户发消息 → Genkit加载会话历史 → Agent根据历史+当前输入决定是否调用工具 → 如果需要调工具,执行工具函数 → 把工具结果注入上下文 → Agent继续推理 → 生成最终回复 → 把用户消息和代理回复写入Firestore。
这个流程里最关键的是工具调用的决策点。Genkit的代理API会让模型自己判断是否需要调工具,你只需要在定义工具时把description写清楚。比如“查询订单状态”这个工具,description要写成“当用户询问订单物流、配送进度、预计到达时间时调用此工具”,而不是简单的“查订单”。description的质量直接决定了模型会不会在正确的时机调工具。
3. 核心细节解析与实操要点
3.1 环境准备与依赖安装
先把基础环境搭起来。我用的Node.js 20 LTS,TypeScript 5.3,Genkit的版本是0.9.x。依赖清单如下:
npm init -y npm install genkit @genkit-ai/googleai @genkit-ai/firebase firebase-admin npm install -D typescript tsx @types/nodegenkit是核心包,@genkit-ai/googleai提供Gemini模型的接入,@genkit-ai/firebase提供Firestore的会话存储适配,firebase-admin用来初始化Firestore连接。
TypeScript配置里需要把strict打开,因为Genkit的工具定义依赖类型推导,strict模式下能提前发现参数类型不匹配的问题。tsconfig.json关键配置:
{ "compilerOptions": { "target": "ES2022", "module": "NodeNext", "moduleResolution": "NodeNext", "strict": true, "esModuleInterop": true, "skipLibCheck": true, "outDir": "dist" }, "include": ["src/**/*.ts"] }实操心得:Genkit的某些类型定义在
moduleResolution: "bundler"下会有解析问题,建议直接用NodeNext。如果你用的是monorepo,确保genkit包在根目录的node_modules里,否则类型推导会失效。
3.2 定义Agent与工具函数的关键细节
定义Agent的时候,最重要的是把systemPrompt写清楚。这个prompt决定了代理的角色、行为边界和回复风格。我的客服代理systemPrompt大概是这样:
你是一个电商客服助手,负责处理订单查询、退换货和地址修改。 你可以调用工具来获取订单信息,但不要编造任何订单数据。 如果用户的问题超出你的能力范围,引导用户联系人工客服。 回复要简洁,每次只问一个必要的问题,不要一次性抛出多个问题。工具函数的定义用genkit.defineTool,关键是把name、description、inputSchema、outputSchema都写完整。inputSchema用Zod来定义,这样Genkit能自动做参数校验和类型推导。
import { z } from "genkit"; export const queryOrderTool = ai.defineTool( { name: "queryOrder", description: "当用户询问订单状态、物流进度、预计送达时间时调用此工具。需要提供订单号。", inputSchema: z.object({ orderId: z.string().describe("订单号,通常是10位数字"), }), outputSchema: z.object({ status: z.string(), estimatedDelivery: z.string(), items: z.array(z.string()), }), }, async (input) => { // 实际项目中这里查数据库 return { status: "已发货", estimatedDelivery: "2024-12-20", items: ["无线耳机", "保护壳"], }; } );这里有个容易忽略的点:outputSchema不是必须的,但强烈建议加上。因为模型需要根据工具返回的结构来决定下一步怎么回复,如果返回的是自由格式的字符串,模型可能会解析错。用结构化schema,模型能更准确地理解返回数据的含义。
3.3 会话存储与Firestore的对接方式
Genkit的会话存储接口是SessionStore,你需要实现get、save、delete三个方法。@genkit-ai/firebase包里已经提供了FirestoreSessionStore,直接拿来用就行:
import { FirestoreSessionStore } from "@genkit-ai/firebase"; import { getFirestore } from "firebase-admin/firestore"; const db = getFirestore(); const sessionStore = new FirestoreSessionStore(db);然后在初始化Genkit的时候把sessionStore传进去:
import { genkit } from "genkit"; import { googleAI } from "@genkit-ai/googleai"; export const ai = genkit({ plugins: [googleAI()], model: "googleai/gemini-1.5-flash", sessionStore, });注意:
FirestoreSessionStore默认把会话存在genkit_sessions集合里,如果你想用自己的集合名,需要自己实现SessionStore接口。我建议直接用默认的,省事,而且和Genkit的版本升级兼容性更好。
3.4 多回合对话的上下文管理策略
多回合对话最容易出问题的地方是上下文长度。Gemini 1.5 Flash的上下文窗口是1M token,看起来很大,但如果你把每一轮的消息都完整保留,几十轮之后还是会接近上限。而且上下文越长,模型的响应延迟越高,成本也越高。
我的策略是滑动窗口+摘要。保留最近10轮完整对话,更早的对话用模型生成一段摘要,把摘要作为系统消息的一部分注入。这样既保留了关键信息,又控制了上下文长度。
Genkit的Agent API本身不提供摘要功能,需要你自己在写入Firestore之前做一层处理。我的做法是在每次写入新消息之前,检查当前会话的消息数量,如果超过20条,就把最早的10条拿出来,调一次ai.generate生成摘要,然后把摘要存到会话文档的summary字段里,同时删除那10条消息。
这个逻辑写起来不复杂,但要注意摘要的触发时机。不要在每次请求都检查,那样会增加延迟。我是在写入代理回复之后异步触发摘要生成,不阻塞当前请求的返回。
4. 实操过程与核心环节实现
4.1 初始化Genkit与注册代理的完整流程
初始化分三步:配置Firebase Admin、创建Genkit实例、注册Agent和工具。
import { initializeApp, cert } from "firebase-admin/app"; import { getFirestore } from "firebase-admin/firestore"; import { genkit } from "genkit"; import { googleAI } from "@genkit-ai/googleai"; import { FirestoreSessionStore } from "@genkit-ai/firebase"; // 第一步:初始化Firebase initializeApp({ credential: cert({ projectId: process.env.FIREBASE_PROJECT_ID, clientEmail: process.env.FIREBASE_CLIENT_EMAIL, privateKey: process.env.FIREBASE_PRIVATE_KEY?.replace(/\\n/g, "\n"), }), }); const db = getFirestore(); // 第二步:创建Genkit实例 const sessionStore = new FirestoreSessionStore(db); export const ai = genkit({ plugins: [googleAI({ apiKey: process.env.GOOGLE_AI_API_KEY })], model: "googleai/gemini-1.5-flash", sessionStore, }); // 第三步:注册工具 export const queryOrderTool = ai.defineTool({...}, async (input) => {...}); export const updateAddressTool = ai.defineTool({...}, async (input) => {...}); // 第四步:定义Agent export const customerServiceAgent = ai.defineAgent({ name: "customerService", systemPrompt: "...", tools: [queryOrderTool, updateAddressTool], });这里有个细节:defineAgent的tools数组里放的是工具定义,不是工具函数本身。Genkit会自动把工具的schema暴露给模型,模型根据schema决定调用哪个工具、传什么参数。
4.2 处理一轮完整对话的代码实现
一轮完整对话的处理流程,我封装成了一个handleMessage函数:
export async function handleMessage(sessionId: string, userMessage: string) { // 1. 获取或创建会话 let session = await sessionStore.get(sessionId); if (!session) { session = await sessionStore.create(sessionId, { userId: "user_001", createdAt: new Date().toISOString(), }); } // 2. 把用户消息写入Firestore await db.collection("sessions").doc(sessionId) .collection("messages").add({ role: "user", content: userMessage, timestamp: new Date().toISOString(), }); // 3. 调用Agent生成回复 const response = await customerServiceAgent.run({ sessionId, input: userMessage, }); // 4. 把代理回复写入Firestore await db.collection("sessions").doc(sessionId) .collection("messages").add({ role: "assistant", content: response.text, toolCalls: response.toolCalls || [], timestamp: new Date().toISOString(), }); // 5. 更新会话活跃时间 await db.collection("sessions").doc(sessionId).update({ lastActiveAt: new Date().toISOString(), }); return response.text; }这段代码里,customerServiceAgent.run是核心。Genkit会自动做几件事:从sessionStore加载会话历史、把历史消息和当前输入拼成模型请求、根据模型的输出判断是否需要调工具、如果需要调工具就执行工具函数、把工具结果注入上下文、再次调用模型生成最终回复。
实操心得:
response.toolCalls里记录了这轮对话调用了哪些工具、传了什么参数、返回了什么结果。这个信息在调试的时候非常有用,建议一定要存下来。我遇到过模型调工具时传错参数的情况,靠toolCalls记录才定位到是description写得不够明确。
4.3 工具调用的参数校验与错误处理
工具函数的参数校验,Genkit会用Zod schema自动做。如果模型传的参数不符合schema,Genkit会抛出一个ToolInputError。你需要捕获这个错误,然后决定是让模型重试还是直接返回错误提示。
我的做法是在工具函数内部做一层业务校验,比如订单号格式不对、订单不存在等情况,返回一个结构化的错误对象,而不是抛异常:
async (input) => { if (!/^\d{10}$/.test(input.orderId)) { return { status: "error", message: "订单号格式不正确,请提供10位数字的订单号", }; } const order = await db.collection("orders").doc(input.orderId).get(); if (!order.exists) { return { status: "error", message: "未找到该订单,请确认订单号是否正确", }; } return { status: "success", data: order.data(), }; }这样模型拿到status: "error"之后,会根据message生成一个友好的回复,引导用户重新提供正确的订单号。比直接抛异常让整个请求失败要好得多。
4.4 会话过期与清理机制的实现
生产环境里,会话不能无限增长。我设置了两条清理规则:活跃会话保留7天,非活跃会话保留24小时。
实现方式是用Firestore的TTL功能,在lastActiveAt字段上设置过期时间。但Firestore的TTL是异步删除,不保证精确时间,所以还需要一个定时任务做兜底清理。
// 每天凌晨3点执行清理 export async function cleanupSessions() { const cutoff = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(); const expiredSessions = await db.collection("sessions") .where("lastActiveAt", "<", cutoff) .where("status", "==", "active") .get(); const batch = db.batch(); expiredSessions.docs.forEach((doc) => { batch.update(doc.ref, { status: "closed" }); }); await batch.commit(); }注意:Firestore的批量操作最多500条,如果过期会话超过500个,需要分批处理。我一般用
while循环加limit(500)来分批查、分批删。
5. 常见问题与排查技巧实录
5.1 模型不调用工具或调用错误工具怎么办
这是最常见的问题。模型不调工具,通常是因为工具的description写得太模糊。比如“查询订单”这个description,模型不知道什么时候该用。改成“当用户询问订单物流状态、配送进度、预计送达时间、订单是否已发货时调用此工具”,模型就能准确判断了。
另一个原因是systemPrompt里没有明确告诉模型“你可以调用工具”。有些模型默认倾向于直接回答,而不是调工具。在systemPrompt里加一句“遇到需要查询数据的问题时,优先调用工具获取准确信息,不要凭记忆回答”,能明显提升工具调用率。
如果模型调用了错误的工具,检查两个工具的description是否有重叠。比如“查询订单”和“查询物流”这两个工具,如果description都写得很宽泛,模型就容易混。解决办法是把边界写清楚:查询订单负责返回订单基本信息,查询物流负责返回配送轨迹,两者不要有交叉。
5.2 多回合对话中上下文丢失的排查思路
上下文丢失的表现是:用户在第1轮说了订单号,第3轮模型又问了一遍订单号。排查步骤:
- 检查Firestore里
messages子集合的消息是否完整。如果消息缺失,说明写入逻辑有问题。 - 检查
sessionStore.get返回的会话历史是否包含了所有消息。可以在handleMessage里打印一下session.messages.length。 - 检查是否触发了摘要逻辑。如果摘要生成有问题,早期消息被删了但摘要没存上,就会导致上下文丢失。
- 检查模型的上下文窗口是否超限。如果超限,Genkit可能会截断历史消息,导致早期信息丢失。
我遇到过一次上下文丢失,最后发现是摘要生成时用的模型和主模型不是同一个,摘要的格式和主模型的预期不一致。统一用同一个模型做摘要就解决了。
5.3 Firestore读写延迟与并发冲突的处理
Firestore的写入延迟通常在100ms以内,但如果你在一个请求里连续做多次写入(写用户消息、写代理回复、更新会话时间),总延迟会累积到300-500ms。优化方法是把多次写入合并成一个批量操作:
const batch = db.batch(); const sessionRef = db.collection("sessions").doc(sessionId); batch.set(sessionRef.collection("messages").doc(), userMessageDoc); batch.set(sessionRef.collection("messages").doc(), assistantMessageDoc); batch.update(sessionRef, { lastActiveAt: new Date().toISOString() }); await batch.commit();并发冲突主要出现在同一个会话同时收到多条消息时。Firestore的事务可以解决这个问题,但事务会增加延迟。我的做法是在应用层加一个简单的锁:用sessionId作为key,在内存里维护一个Map<string, Promise>,同一个会话的请求串行执行。
5.4 常见问题速查表
| 问题现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 模型不调工具 | description太模糊 | 检查工具description | 补充触发场景描述 |
| 调错工具 | 工具边界重叠 | 对比多个工具的description | 明确各自职责范围 |
| 上下文丢失 | 摘要逻辑异常 | 检查摘要生成和存储 | 统一摘要模型 |
| 写入延迟高 | 多次单独写入 | 检查写入次数 | 合并为批量操作 |
| 并发冲突 | 同一会话并发请求 | 检查请求日志 | 应用层加会话锁 |
| 工具参数错误 | schema定义不严 | 检查Zod schema | 加严校验规则 |
| 会话无限增长 | 无清理机制 | 检查会话数量 | 加TTL和定时清理 |
6. 性能优化与扩展方向
6.1 减少模型调用次数的缓存策略
多回合代理的成本大头在模型调用上。每一轮对话至少调一次模型,如果涉及工具调用,就是两次(一次决定调工具,一次生成最终回复)。优化方向是缓存常见问题的回复。
我的做法是维护一个faq_cache集合,key是用户问题的向量化表示,value是标准回复。用户发消息后,先算一下问题的embedding,在缓存里查相似度超过0.95的,直接返回缓存回复,不走模型。这个策略对客服场景特别有效,因为很多用户问的是重复问题。
Genkit本身有embedding的API,用ai.embed就能生成向量。Firestore不支持向量检索,但可以用firebase-admin的vector扩展,或者把向量存到专门的向量数据库里。我图省事,直接用了一个简单的内存缓存,key是问题的MD5,只缓存完全匹配的问题,命中率大概30%,但已经能省不少成本了。
6.2 多代理协作的架构演进思路
单个代理能处理的问题有限。当业务变复杂时,可以考虑多代理协作。比如一个“路由代理”负责判断用户意图,然后把请求分发给“订单代理”、“退换货代理”、“投诉代理”。
Genkit的代理API支持这种模式。你可以定义多个Agent,然后在路由代理的systemPrompt里告诉它“根据用户意图,调用对应的子代理”。子代理的调用方式和工具调用类似,只是返回的不是数据,而是一段回复。
这种架构的好处是每个子代理的systemPrompt可以写得很专注,工具集也可以更精简,模型的判断准确率会更高。代价是调用链变长,延迟增加。我的经验是:当单代理的工具超过5个,或者systemPrompt超过500字时,就该考虑拆分了。
6.3 从原型到生产的检查清单
原型跑通之后,上线之前需要检查这些项:
- 会话存储是否从内存切换到了Firestore
- 是否加了会话过期和清理机制
- 是否加了错误处理和重试逻辑
- 是否加了请求日志和工具调用日志
- 是否加了速率限制,防止单个用户刷接口
- 是否加了敏感词过滤,防止代理输出不当内容
- 是否加了成本监控,跟踪每天的模型调用量和token消耗
- 是否做了压力测试,确认并发量下的响应时间
实操心得:速率限制我建议用
express-rate-limit或者类似的中间件,按userId做限流,而不是按IP。因为同一个用户可能从多个设备访问,按IP限流会误伤。限流阈值我设的是每分钟10次对话请求,超过就返回“请求过于频繁,请稍后再试”。
7. 我在实际搭建中踩过的坑与经验总结
第一个坑是Firestore的undefined值问题。Firestore不接受undefined作为字段值,但TypeScript的可选属性默认就是undefined。我在写入消息时,toolCalls字段有时候是undefined,直接写入会报错。解决办法是在写入之前做一次清洗,把undefined转成null或者空数组。
第二个坑是Genkit的会话ID和Firestore文档ID的映射。Genkit的sessionStore.get如果找不到会话,默认行为是返回null,但有些版本的实现会抛异常。我在代码里加了一层try-catch,确保找不到会话时能正常创建新会话,而不是让整个请求失败。
第三个坑是模型的工具调用格式不一致。Gemini 1.5 Flash和Gemini 1.5 Pro在工具调用的返回格式上有细微差别,Flash有时候会把工具调用的参数放在text字段里而不是toolCalls字段里。解决办法是在解析响应时同时检查这两个字段,做兼容处理。
第四个坑是摘要生成的时机。我一开始是在每次请求开始时检查消息数量,超过阈值就生成摘要。结果发现这样会让每轮对话的延迟增加200-300ms。后来改成异步生成,在返回响应之后用setImmediate触发摘要任务,不阻塞主流程,体验好很多。
第五个坑是Firestore的复合索引。我按role和timestamp查询消息时,Firestore报错说缺少索引。去控制台建索引花了10分钟,期间查询一直失败。建议在开发阶段就把可能用到的复合索引都建好,别等到上线才发现。
最后分享一个小技巧:在systemPrompt里加一句“如果用户提供了订单号,先调用queryOrder工具确认订单存在,再进行后续操作”。这句话能显著减少模型在订单不存在的情况下继续瞎编回复的情况。我试过不加这句话,模型会在订单号错误时仍然生成一段“您的订单正在配送中”的假回复,加了之后模型会先调工具,发现订单不存在,然后引导用户重新提供订单号。
这个代理原型我跑了大概两周,处理了2000多轮对话,工具调用的准确率在92%左右,剩下的8%主要是用户输入太模糊导致模型判断困难。后续我打算把路由代理加上,把订单查询和退换货拆成两个子代理,看看准确率能不能再提一提。