ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Genkit 多回合 AI 代理实战:TypeScript 工具定义与 Firestore 状态持久化

Genkit 多回合 AI 代理实战:TypeScript 工具定义与 Firestore 状态持久化 多回合对话代理这件事我在过去一年里反复折腾过好几套方案。最早用纯手写状态机后来换成 LangChain 的 AgentExecutor再后来接触到 Genkit 的代理 API才算真正找到一个在 TypeScript 生态里既能快速跑通、又能撑住生产流量的路子。Genkit 是 Google 开源的一套 AI 应用开发框架它的代理 API 专门用来处理多回合这种场景——也就是用户和 AI 之间来回好几轮、每轮都要带着上下文、还要能调用外部工具的那种交互。这篇文章我会把从零搭一个多回合 AI 代理的完整过程拆开讲包括为什么选 Genkit、代理循环到底怎么转、工具怎么定义、状态怎么持久化到 Firestore、以及我在实测中踩过的那些坑。适合已经会 TypeScript、想认真做一个能上线的对话代理的开发者也适合刚接触 Genkit 想看看它到底能干什么的朋友。1. 为什么多回合代理不能靠拼 prompt糊弄过去1.1 单回合和多回合的本质差别在哪很多人第一次做对话功能思路是把历史消息拼成一个长字符串塞进 prompt然后调一次模型拿回复。这在单回合问答里没问题但一旦进入多回合代理的场景这套做法很快就崩了。原因不在于模型能力而在于代理这个词本身就意味着它要自己做决策这一轮是该直接回答还是该去查数据库还是该调用某个工具拿到结果再回答。这个决策过程不是一次模型调用能完成的它需要一个循环。我打个比方。单回合问答像是你问路人最近的便利店在哪他直接告诉你方向就完事了。多回合代理像是你雇了一个助理你说帮我订一张下周去上海的高铁票他得先问你具体哪天、几点、坐二等座还是商务座然后去查余票发现没票了还得回来跟你商量换一班最后确认下单。这中间有多次往返、有工具调用、有状态记忆。你不可能用一句 prompt 把这一整套流程描述清楚还指望模型每次都执行对。Genkit 的代理 API 解决的正是这个循环问题。它把模型思考 → 决定调用工具 → 执行工具 → 把结果喂回模型 → 再思考这个循环封装成了框架能力你只需要定义好工具和代理的初始状态剩下的循环由框架驱动。这一点是我最终选择它的核心原因。1.2 代理循环里到底发生了什么在深入代码之前得先把代理循环的机制讲透不然你写出来的东西只是照抄出了问题根本不知道怎么查。一个典型的多回合代理循环包含这么几个阶段第一阶段是输入组装。把当前用户消息、历史对话、系统指令、可用工具的描述全部组装成模型能理解的格式。这里的关键是工具描述——模型是根据你给的文字描述来决定要不要调用某个工具的描述写得含糊模型就会乱调或者不调。第二阶段是模型推理。模型拿到组装好的输入后输出两种可能一种是直接给最终回答另一种是要求调用某个工具并给出参数。Genkit 里这个输出是结构化的不是纯文本所以你能可靠地判断模型想干什么。第三阶段是工具执行。如果模型要求调用工具框架就去执行对应的函数拿到返回值。这个返回值可能是数据库查询结果、API 响应、计算结果等等。第四阶段是结果回灌。把工具的执行结果作为新一轮输入的一部分再次喂给模型。模型看到工具结果后可能给出最终回答也可能决定再调用另一个工具。这个循环会一直转直到模型给出最终回答或者达到你设置的最大轮数上限。理解了这个循环你就能明白为什么最大轮数这个参数如此重要。没有上限的循环在某些边界情况下会无限转下去烧钱不说还会把请求卡死。我一般会把它设在 5 到 10 之间具体看业务复杂度。1.3 Genkit 在这个链条里替你做了什么如果不用框架上面这套循环你得自己写自己判断模型输出格式、自己调度工具、自己管理消息历史、自己处理异常重试。这些活儿单拎出来都不难但拼在一起就是几百行胶水代码而且很容易在边界情况上出 bug。Genkit 的代理 API 把这些都接管了。你定义工具用genkit.defineTool定义代理用genkit.defineFlow或者更专门的 agent 抽象框架负责循环调度。它还内置了可观测性每次模型调用、每次工具执行都会留下 trace出问题的时候能直接看到是哪一步卡住了。这一点在我排查模型为什么反复调用同一个工具这类问题时帮了大忙。另外它对 TypeScript 的类型支持做得比较到位。工具的参数 schema 用 Zod 定义模型返回的工具调用参数会自动按 schema 校验类型不对会直接报错而不是悄悄传个 undefined 进去。这个细节看起来小但省了我很多调试时间。2. 环境搭建与项目骨架别一上来就写业务逻辑2.1 依赖安装与版本选择的坑搭 Genkit 项目第一步是装依赖。核心包是genkit然后根据你用哪家模型装对应的插件比如genkit-ai/googleai或者genkit-ai/vertexai。这里有个我踩过的坑Genkit 的版本迭代比较快不同小版本之间 API 有变动尤其是工具定义和 flow 定义的部分。我建议在 package.json 里锁死版本号别用^让它自动升级否则某天早上起来构建就挂了。npm install genkit0.9.x genkit-ai/googleai0.9.x zod npm install -D typescript tsx types/nodeZod 是必须的因为工具参数和 flow 的输入输出都用它定义 schema。tsx 用来直接跑 TypeScript 文件省去编译步骤开发阶段很方便。环境变量方面模型服务的凭证通过环境变量注入别硬编码在代码里。我一般会在项目根目录放一个.env文件用 dotenv 加载。这里要提醒一句.env一定要加进.gitignore我见过有人把凭证提交到仓库的后果很麻烦。2.2 目录结构怎么划分才不混乱项目小的时候随便放都行但代理类项目一旦工具有十几个、flow 有好几条目录结构就很重要了。我习惯这么分src/ genkit.ts // Genkit 实例初始化全局唯一 tools/ // 所有工具定义一个文件一个工具或一组相关工具 flows/ // 代理 flow 定义 schemas/ // Zod schema 集中管理 services/ // 外部服务封装比如 Firestore 客户端 index.ts // 入口把 Genkit 实例单独放一个文件很关键。因为工具定义、flow 定义都要引用这个实例如果每个文件各自new Genkit()会出现实例不匹配的问题工具注册不上去。我一开始就犯过这个错工具死活调不到查了半天才发现是两个实例。// src/genkit.ts import { genkit } from genkit; import { googleAI } from genkit-ai/googleai; export const ai genkit({ plugins: [googleAI()], model: googleai/gemini-1.5-flash, });这个文件导出唯一的ai实例其他所有地方都从这里 import。2.3 先跑通一个最小可用的代理再扩展我的建议是别一上来就设计复杂的工具集。先定义一个最简单的工具比如获取当前时间然后写一个能调用它的代理把整个循环跑通。这一步的目的是验证环境、验证模型能正确识别工具、验证循环能正常结束。等这个最小闭环跑通了再往上加业务工具。// src/tools/getTime.ts import { z } from zod; import { ai } from ../genkit; export const getTimeTool ai.defineTool( { name: getCurrentTime, description: 获取当前的日期和时间当用户询问现在几点或今天日期时使用, inputSchema: z.object({ timezone: z.string().optional().describe(时区例如 Asia/Shanghai), }), outputSchema: z.string(), }, async ({ timezone }) { const now new Date(); return now.toLocaleString(zh-CN, { timeZone: timezone ?? Asia/Shanghai }); } );注意 description 的写法。我特意写了当用户询问现在几点或今天日期时使用这是在给模型提示调用时机。工具描述写得越具体模型误调、漏调的概率越低。这是我在实际项目里反复验证过的经验。3. 工具定义的门道模型会不会用全看你怎么描述3.1 工具描述是给模型看的不是给人看的这是我最想强调的一点。很多开发者写工具描述的时候是按给人看文档的思路写的比如这个工具用于查询用户信息。但模型需要的是更明确的触发条件。好的工具描述应该回答三个问题这个工具做什么、什么时候该用它、参数是什么意思。我举个反面例子。之前有个工具叫searchProducts描述就写了搜索商品。结果模型在用户问帮我看看有没有便宜的耳机的时候有时候调这个工具有时候不调行为很不稳定。后来我把描述改成根据关键词搜索商品库当用户想查找、浏览或比较具体商品时使用关键词应该是商品名称或类别调用稳定性立刻上来了。3.2 参数 schema 的设计直接影响调用成功率参数用 Zod 定义但怎么设计参数也有讲究。我的经验是参数尽量少、尽量扁平、尽量给默认值。模型处理嵌套对象参数的能力明显弱于扁平参数。如果一个工具需要五个参数我会考虑是不是能拆成两个工具或者把某些参数设成可选并给合理默认值。// 不推荐嵌套太深 inputSchema: z.object({ query: z.object({ filters: z.object({ priceRange: z.object({ min: z.number(), max: z.number() }), }), }), }) // 推荐扁平化 inputSchema: z.object({ keyword: z.string().describe(搜索关键词), minPrice: z.number().optional().describe(最低价格不填则不限制), maxPrice: z.number().optional().describe(最高价格不填则不限制), })另外每个字段的.describe()一定要写。模型在生成参数时会参考这些描述尤其是当字段名不够直观的时候。比如type这个字段光看名字模型不知道是商品类型还是用户类型加上描述就清楚了。3.3 工具执行里的异常处理不能省工具函数执行时可能抛异常比如数据库连不上、外部 API 超时。如果异常直接往上抛整个代理循环就断了用户看到的是一个报错。更好的做法是在工具内部捕获异常返回一个结构化的错误信息让模型知道这个工具这次没成功它可以选择重试或者换个方式回答。async ({ keyword }) { try { const results await productService.search(keyword); return { success: true, items: results }; } catch (err) { return { success: false, error: 搜索服务暂时不可用请稍后重试 }; } }这样模型拿到success: false之后可以告诉用户搜索服务暂时有问题而不是整个对话崩掉。这个模式我在生产环境用了很久稳定性提升很明显。3.4 工具数量不是越多越好新手容易犯的错是把所有能想到的功能都做成工具一口气定义二三十个。结果模型在每一轮都要从这一大堆工具里挑选择困难误调率飙升。我的经验是单个代理挂的工具控制在 5 到 8 个以内超过这个数量就考虑拆分代理或者用工具分组的方式做二级路由。如果业务确实需要很多工具可以做一个元工具模式先让模型判断用户意图属于哪个大类再进入对应的子代理子代理里只挂该类别的工具。这样每一轮模型面对的工具集都是收敛的决策质量高很多。4. 代理 flow 的编写循环、状态与中断4.1 用 defineFlow 还是用 agent 抽象Genkit 提供了两种写代理的方式。一种是底层的defineFlow你自己控制循环另一种是更高层的 agent 抽象框架帮你管循环。我一般推荐先用高层抽象快速跑通等遇到需要精细控制的场景再下沉到 defineFlow。高层抽象的好处是代码量少你只需要定义工具集和系统指令框架自动处理模型调用 → 工具执行 → 结果回灌的循环。缺点是灵活性受限比如你想在每轮循环之间插入自定义逻辑记录日志、做内容审核就得用 defineFlow 自己写。// 高层抽象示意 const chatAgent ai.defineAgent({ name: shoppingAssistant, model: googleai/gemini-1.5-flash, system: 你是一个购物助手帮助用户查找和比较商品。回答要简洁必要时调用工具查询真实数据。, tools: [searchProductsTool, getProductDetailTool, compareProductsTool], });4.2 系统指令怎么写才能约束住模型系统指令system prompt是代理行为的宪法。我写系统指令一般包含四块内容角色定位、能力边界、工具使用规则、输出格式要求。角色定位告诉模型它是谁。能力边界告诉它什么不能做比如不要编造商品价格所有价格必须来自工具查询结果。工具使用规则告诉它什么时候该调工具比如当用户询问具体商品信息时必须先调用 searchProducts 工具。输出格式要求告诉它回答的风格比如回答控制在三句话以内用口语化表达。这四块里能力边界是最容易被忽略但最重要的。模型天生有编造的倾向如果你不明确禁止它会在工具没返回结果的时候自己编一个看起来合理的答案。我在系统指令里会反复强调不确定的信息必须通过工具确认工具没有返回的信息要如实告诉用户不知道。4.3 多回合状态怎么在轮次之间传递多回合的核心是状态。用户第一轮说我想买耳机第二轮说要便宜点的代理得知道便宜点的是在修饰耳机。这个上下文传递靠的是消息历史。Genkit 的代理 API 里消息历史是一个数组每轮对话结束后把用户消息和模型回复追加进去下一轮把整个数组传进去。但这里有个陷阱历史不能无限增长。模型有上下文窗口限制历史太长会超限报错而且 token 消耗也大。我的做法是保留最近 N 轮完整历史更早的做摘要压缩。比如保留最近 10 轮原文再往前的用一段摘要代替。摘要可以定期用模型生成把用户想买耳机、预算 500 以内、偏好入耳式这种关键信息提炼出来。interface ConversationState { messages: Message[]; summary?: string; metadata: { userId: string; sessionId: string; turnCount: number; }; }4.4 中断与恢复用户中途改主意怎么办真实场景里用户经常中途改主意。第一轮说帮我订明天的票第二轮突然说算了改成后天。代理需要能识别这种意图切换并且不被之前的状态带偏。处理这个问题的关键是系统指令里要明确以用户最新消息为准。同时如果代理已经执行了某些有副作用的操作比如已经下单需要有回滚或确认机制。我一般会在执行有副作用的工具前让代理先跟用户确认一次确认后才真正执行。这个确认-执行两步走模式能避免很多误操作。5. 用 Firestore 做状态持久化会话不能只活在内存里5.1 为什么内存存状态在生产环境行不通开发阶段把会话状态存在内存的 Map 里跑起来没问题。但一上线就出问题服务重启状态全丢、多实例部署时请求打到不同实例上状态对不上、用户换个设备就找不到之前的对话。所以生产环境必须把状态持久化到外部存储。Firestore 是我在 Genkit 项目里用得比较多的选择因为它是文档型数据库天然适合存会话这种半结构化的数据而且和 Genkit 生态集成得比较顺。当然你用别的数据库也行核心思路是一样的。5.2 会话文档的数据结构设计Firestore 里我会给每个会话建一个文档路径类似sessions/{sessionId}。文档结构大致是这样// Firestore 文档结构 { userId: user_123, createdAt: Timestamp, updatedAt: Timestamp, turnCount: 12, summary: 用户想购买降噪耳机预算 800 以内已推荐三款, messages: [ { role: user, content: 我想买个耳机, timestamp: ... }, { role: assistant, content: 好的请问有什么偏好吗, timestamp: ... }, // ... ] }这里有个设计决策messages 是存成数组还是存成子集合。数组的好处是读取一次就能拿到全部历史简单缺点是文档有大小限制1MB历史特别长的时候会超。子集合没有大小限制但读取需要额外查询。我的经验是配合前面说的历史压缩策略数组方式在绝大多数场景下够用而且更简单。5.3 读写时机与并发控制状态读写的时机很关键。我的做法是每轮对话开始时读取会话文档对话结束后写回。中间不频繁读写减少数据库压力。但这里有个并发问题如果同一个会话同时有两个请求进来比如用户快速连发两条消息可能出现写覆盖。Firestore 支持事务我一般用事务来保证读-改-写的原子性。或者更简单的方式是给会话加一个版本号写入时检查版本号是否变化变了就重试。async function saveSession(sessionId: string, state: ConversationState) { const ref db.collection(sessions).doc(sessionId); await db.runTransaction(async (tx) { const doc await tx.get(ref); const currentVersion doc.exists ? doc.data()!.version : 0; tx.set(ref, { ...state, version: currentVersion 1, updatedAt: FieldValue.serverTimestamp(), }); }); }5.4 历史压缩的触发策略前面提到历史要压缩具体什么时候触发我的策略是当消息数量超过阈值比如 20 条时触发一次压缩。压缩时把最早的 10 条消息交给模型生成摘要然后把摘要存到 summary 字段同时从 messages 数组里删掉这 10 条。这里要注意压缩本身也是一次模型调用有成本。所以阈值不能设太低否则压缩太频繁。20 条这个数字是我根据实际对话长度分布调出来的你可以根据自己的业务特点调整。6. 实测中踩过的坑与排查思路6.1 模型反复调用同一个工具的死循环这是我遇到最多的一个问题。表现是代理卡住不动日志里看到同一个工具被调了七八次。原因通常是工具返回的结果没有让模型满意模型以为没拿到数据就再调一次。排查思路是这样的先看工具的返回值是不是符合模型预期。有一次我的工具返回了一个空数组模型理解成查询失败就一直重试。后来我改成返回{ success: true, items: [], message: 没有找到匹配的商品 }模型看到明确的没有找到就不会再重试了。另一个原因是工具描述里没有说明这个工具调用一次就够了。有些模型会倾向于多调几次确认。我在系统指令里加了一句同一个工具在同一轮对话中不要重复调用除非用户明确要求重新查询死循环问题基本消失了。6.2 工具参数类型不匹配导致的静默失败Zod schema 定义的是z.number()但模型有时候会传字符串500进来。如果 schema 校验严格会直接报错如果用了.coerce会自动转换。我一开始没注意这个工具收到字符串类型的价格参数做数值比较时得到意外结果但又不报错排查了很久。我的建议是对可能被模型传成字符串的数值参数用z.coerce.number()。对枚举类型的参数一定要用z.enum()而不是z.string()这样模型传了非法值会立刻报错而不是带着错误值往下走。6.3 上下文超限的报错与应对对话轮次多了之后会遇到上下文窗口超限的报错。这个报错信息有时候不太直观可能只显示一个 token 数量超限。我的应对是提前做 token 估算在组装输入前先算一下大概的 token 数超过阈值就先触发压缩。估算 token 有个粗略公式英文大约 4 个字符 1 个 token中文大约 1.5 个字符 1 个 token。这个不精确但用来做预警够了。更精确的做法是用对应模型的 tokenizer但引入额外依赖看你的精度要求。6.4 工具执行超时拖垮整个请求外部 API 调用如果没设超时一旦对方服务慢整个代理请求就卡住了。我给所有涉及外部调用的工具都加了超时控制一般设 5 秒。超时后返回一个明确的错误让模型决定是重试还是告诉用户稍后再试。async function withTimeoutT(promise: PromiseT, ms: number): PromiseT { return Promise.race([ promise, new PromiseT((_, reject) setTimeout(() reject(new Error(工具执行超时)), ms) ), ]); }这个withTimeout包装函数我在每个外部调用工具里都用简单但有效。7. 从能跑到好用几个提升代理质量的经验7.1 给代理加思考过程能显著提升决策质量有些模型支持在给出最终答案前先输出一段思考过程。开启这个能力后模型在决定调用哪个工具时会更有条理误调率明显下降。代价是 token 消耗增加响应变慢。我的做法是在复杂代理上开启简单代理上关闭按需选择。7.2 用少量示例引导工具调用格式如果某个工具的调用格式比较特殊可以在系统指令里给一两个调用示例。比如当用户说帮我对比 A 和 B时应该先调用 getProductDetail 分别获取 A 和 B 的信息再调用 compareProducts。这种示例对模型的行为引导效果很好比纯文字描述管用。7.3 监控与日志出问题能快速定位生产环境一定要有监控。我关注几个指标每轮对话的平均工具调用次数、工具调用失败率、平均响应时间、上下文压缩触发频率。这些指标异常往往预示着问题。比如工具调用次数突然上升可能是某个工具的描述让模型产生了困惑。Genkit 的 trace 功能配合日志系统能把每次对话的完整链路记录下来。我在排查线上问题时第一件事就是拉出对应 session 的 trace看模型在哪一步做了什么决策。7.4 灰度发布与回滚代理的行为受模型版本、系统指令、工具描述多个因素影响任何一处改动都可能带来行为变化。所以每次改动我都走灰度先放 5% 流量观察指标没问题再逐步放量。系统指令和工具描述的改动尤其要谨慎因为它们对模型行为的影响很微妙有时候改一个词效果就完全不同。8. 关于 TypeScript 类型安全在代理项目里的实际价值8.1 类型安全不只是编译期的事在代理项目里TypeScript 的类型系统帮我在两个地方省了大量时间。一是工具参数的定义Zod schema 和 TypeScript 类型能互相推导工具函数里拿到的参数是强类型的写业务逻辑时 IDE 能给出准确的补全。二是会话状态的结构定义好ConversationState接口后任何地方访问状态字段都有类型检查避免了字段名拼错这类低级错误。8.2 用类型约束工具返回值工具返回值也建议定义明确的类型。我见过有人工具返回any结果模型拿到的数据结构不稳定有时候是对象有时候是字符串导致后续处理逻辑到处是类型判断。定义好返回类型后工具实现必须符合这个类型模型拿到的数据结构就是稳定的。const SearchResultSchema z.object({ success: z.boolean(), items: z.array(z.object({ id: z.string(), name: z.string(), price: z.number(), })), message: z.string().optional(), }); type SearchResult z.infertypeof SearchResultSchema;用z.infer从 schema 推导出 TypeScript 类型一处定义两处用这是我在 Genkit 项目里的标准做法。8.3 泛型在 flow 定义里的应用如果项目里有多个结构相似的 flow可以用泛型抽象出公共逻辑。比如所有代理 flow 都需要读取会话 → 执行代理 → 保存会话这个骨架只是中间的代理逻辑不同。用泛型把这个骨架抽出来能减少大量重复代码。不过要注意别过度抽象代理逻辑差异大的时候强行抽象反而增加理解成本。9. 部署与运维让代理稳定跑在生产环境9.1 部署形态的选择Genkit 项目可以部署成普通的 Node.js 服务也可以部署到支持容器化的平台。我一般部署成容器因为依赖可控、扩缩容方便。部署时要注意几个点环境变量通过平台注入而不是打包进镜像、健康检查接口要暴露、日志输出到标准输出方便采集。9.2 冷启动与预热如果部署在会冷启动的环境上第一次请求可能特别慢因为要初始化 Genkit 实例、加载模型客户端。我的做法是加一个预热逻辑服务启动后主动发一个简单的模型请求把连接池和缓存预热起来。这样用户的第一条消息不会等太久。9.3 成本控制代理类应用的成本主要来自模型调用。多回合代理每轮可能调用多次模型成本比单次问答高不少。控制成本的手段有几个合理设置最大轮数、历史压缩减少输入 token、简单场景用小模型、缓存重复的工具查询结果。我一般会先跑一段时间收集真实数据看看成本分布再针对性优化。9.4 限流与降级生产环境必须有限流。我按用户维度做限流防止单个用户刷爆配额。降级策略也要准备好模型服务不可用时返回一个友好的提示而不是报错工具服务不可用时让代理基于已有信息回答而不是卡死。这套东西我从零搭到上线大概花了两周其中一半时间花在调试工具描述和系统指令上。代理类项目的开发代码量其实不大难的是让模型的行为稳定可控。我的体会是把工具描述和系统指令当成产品文案来打磨反复测试、反复调整比堆代码重要得多。Genkit 的代理 API 把循环调度这些脏活接过去了剩下的就是你在怎么让模型理解你的意图这件事上花心思。
返回列表