Genkit 代理 API 实战:TypeScript + Firebase 构建多回合 AI 代理
多回合对话代理这件事我在去年做过一个客服工单自动分流的项目当时用传统的方式把对话历史拼成一个超长 prompt 塞给模型结果就是 token 消耗爆炸、上下文一长模型就开始失忆、多轮工具调用更是乱成一锅粥。后来接触到 Genkit 的代理 APIAgent API才意识到多回合代理的核心难点根本不在调模型这一步而在于状态怎么管、工具怎么编排、回合怎么推进。这篇就围绕 Genkit 的代理 API 构建多回合 AI 代理这条主线把我在 TypeScript Firebase 技术栈下踩过的坑、验证过的方案完整拆一遍适合已经会写基础 TypeScript、想真正把 AI 代理跑进生产环境的开发者参考。1. 先搞清楚多回合代理到底难在哪1.1 单次调用和多回合代理的本质区别很多人第一次做 AI 功能脑子里想的是用户输入一句话模型返回一句话这就是典型的单次调用single-turn。这种模式简单到几乎不需要架构拼 prompt、发请求、拿结果、渲染。但一旦需求变成用户和 AI 来回聊十几轮中间还要查数据库、调外部接口、根据结果决定下一步做什么单次调用的思路就彻底不够用了。多回合代理的本质区别在于三点。第一是状态持久化每一轮的输入输出都要被记住而且不是简单堆在数组里要能被后续回合按需检索。第二是决策循环模型不只是生成文本它要决定我现在是直接回答还是先调用某个工具拿到结果再回答这个循环可能跑好几圈。第三是工具编排代理能调用的工具往往不止一个什么时候调哪个、参数怎么传、失败了怎么重试都需要一套机制来兜底。我见过太多项目把这三件事全塞进一个巨大的 prompt 里靠请你记住之前的对话这种自然语言指令来维持状态。短期 demo 能跑一上量就崩。Genkit 的代理 API 之所以值得单独拿出来讲就是因为它把这三件事抽象成了框架层面的能力而不是让你用 prompt 硬凑。1.2 Genkit 代理 API 提供的核心抽象Genkit 是 Google 开源的一套 AI 应用开发框架TypeScript 和 Go 都有支持。它的代理 API 核心围绕几个概念展开Tool工具、Flow流程、Session/State会话状态、Generate 与 GenerateStream生成与流式生成。理解这几个词基本就理解了它的设计哲学。Tool 就是你暴露给模型调用的函数比如查询订单状态计算运费发送邮件。你用一个 schema 描述它的输入输出Genkit 会自动把这个描述转成模型能理解的工具定义。Flow 是一段有名字、有输入输出类型、可被观测的编排逻辑你可以把它理解成一个可以被追踪的代理回合。Session 则负责把多轮对话的状态存下来让下一轮能接着上一轮继续。这套抽象最舒服的地方在于它把模型决策和业务逻辑分开了。模型只负责决定调哪个工具、传什么参数真正的业务执行在你的 TypeScript 代码里类型安全、可测试、可调试。这比让模型直接生成一段代码去执行要靠谱得多。1.3 为什么选 TypeScript Firebase 这套组合关键词里出现了 Firebase 和 TypeScript这不是偶然。Genkit 对 TypeScript 的支持是一等公民类型推导做得相当到位工具函数的输入输出 schema 能直接推导出 TS 类型写起来几乎不用手动标注。而 Firebase 在这套组合里承担的是基础设施角色Firestore 存会话状态、Cloud Functions 跑代理逻辑、Firebase Auth 做用户身份。我实测下来这套组合最大的优势是部署链路短。你本地用 Genkit 的开发者 UI 调试代理调通了直接firebase deploy把 Flow 部署成 Cloud Function前端用 Firebase SDK 调用中间不需要自己搭服务器、配网关、搞鉴权。对于中小团队来说这能省掉大量运维成本。当然如果你的业务已经跑在别的云上Genkit 本身不绑定 Firebase状态存储换成 Redis 或 Postgres 也完全可行后面我会讲怎么替换。2. 环境搭建与项目骨架别一上来就写业务2.1 依赖安装与版本选择的坑初始化项目这一步看似简单但版本问题能让你卡半天。Genkit 的包拆得比较细核心是genkit模型插件按厂商分比如genkit-ai/googleai、genkit-ai/vertexaiFirebase 集成是genkit-ai/firebase。我的建议是先锁定 Genkit 主版本再让插件跟着主版本走因为插件和核心版本不匹配是最高频的报错来源。npm init -y npm install genkit genkit-ai/googleai genkit-ai/firebase npm install -D typescript tsx types/node这里有个细节tsx是我强烈建议装上的它让你能直接tsx watch src/index.ts跑 TypeScript 而不用先编译调试代理逻辑时改一行看一行效率比tsc node高太多。另外注意关键词里提到的baseUrl和moduleResolutionnode10在 TypeScript 7.0 会被弃用这件事——如果你用的是较新的 TS 版本tsconfig.json里就别再写baseUrl了路径别名改用paths配合moduleResolution: bundler或node16否则升级 TS 时会收到一堆弃用警告。2.2 tsconfig 的关键配置项TypeScript 配置直接决定了你的开发体验。下面这份是我在多个 Genkit 项目里沉淀下来的配置重点看注释部分{ compilerOptions: { target: ES2022, module: NodeNext, moduleResolution: NodeNext, strict: true, esModuleInterop: true, skipLibCheck: true, outDir: lib, rootDir: src, paths: { /*: [./src/*] } }, include: [src/**/*] }strict: true别关Genkit 的类型推导在严格模式下才能发挥最大价值工具函数的参数类型错了编译期就能发现。moduleResolution用NodeNext而不是老的node10既避开了弃用警告又能正确处理 ESM 包。skipLibCheck打开是为了跳过第三方库的类型检查能显著加快编译速度代价是库自身的类型错误你看不到但对业务代码没影响。2.3 目录结构怎么划分才不混乱代理项目最容易失控的地方就是文件越堆越多最后自己都找不到某个工具定义在哪。我习惯按职责而不是类型来分目录src/ agents/ # 代理定义每个代理一个文件 supportAgent.ts tools/ # 工具函数按业务域再分子目录 order/ queryOrder.ts cancelOrder.ts flows/ # 对外暴露的 Flow chatFlow.ts state/ # 会话状态管理 sessionStore.ts config/ # 模型配置、环境变量 genkit.ts这样分的好处是当你要改订单相关的能力所有相关代码都在tools/order/下不用满项目搜。config/genkit.ts里统一初始化 Genkit 实例并注册插件其他文件 import 这个实例即可避免重复初始化。提示Genkit 实例必须是单例。如果你在多个文件里各自genkit()初始化会出现插件重复注册、工具找不到的问题。统一从一个 config 文件导出。3. 用 Tool 把业务能力暴露给模型3.1 定义一个工具的最小完整结构工具是代理和真实世界之间的桥梁。一个工具定义包含三部分名字、描述、输入输出 schema。名字和描述是给模型看的schema 是给运行时做校验和类型推导用的。下面是一个查询订单状态的工具import { z } from genkit; import { ai } from ../config/genkit; export const queryOrderTool ai.defineTool( { name: queryOrder, description: 根据订单号查询订单的当前状态、金额和物流信息。当用户询问订单进度时调用。, inputSchema: z.object({ orderId: z.string().describe(订单号通常是 12 位数字), }), outputSchema: z.object({ status: z.enum([pending, shipped, delivered, cancelled]), amount: z.number(), trackingNo: z.string().optional(), }), }, async ({ orderId }) { const order await db.collection(orders).doc(orderId).get(); if (!order.exists) { throw new Error(订单 ${orderId} 不存在); } return order.data() as OrderShape; } );注意description的写法。它不是写给人看的注释而是模型决定要不要调用这个工具的唯一依据。我踩过的坑是描述写得太笼统比如查询订单模型经常在用户只是闲聊提到订单两个字时就乱调。后来我把描述改成当用户询问订单进度时调用并明确列出触发场景误调用率明显下降。3.2 工具描述怎么写模型才听得懂工具描述的质量直接决定代理的决策质量。我总结了三条经验。第一写清楚什么时候调用而不是这个工具是什么。模型需要的是触发条件不是功能说明书。第二把参数的含义和格式写进 schema 的 describe 里比如订单号是几位、日期用什么格式模型会照着填。第三明确边界如果两个工具功能相近要在描述里说清楚区别否则模型会随机选。举个例子我有个项目同时有查询物流和查询订单两个工具。一开始模型总是混着调。后来我在查询物流的描述里加了仅当用户明确询问包裹位置或配送进度时调用不涉及订单金额和状态在查询订单的描述里加了涉及订单金额、支付状态、取消操作时调用两个工具的调用准确率立刻上来了。3.3 工具执行失败时怎么让代理优雅兜底工具执行抛异常是常态数据库连不上、外部接口超时、参数不合法。如果直接让异常冒泡整个代理回合就断了用户体验很差。我的做法是在工具内部捕获可预期的错误并返回结构化的失败结果而不是抛异常async ({ orderId }) { try { const order await db.collection(orders).doc(orderId).get(); if (!order.exists) { return { found: false, reason: 订单不存在 }; } return { found: true, data: order.data() }; } catch (e) { return { found: false, reason: 查询服务暂时不可用 }; } }这样模型拿到的是查询失败原因是订单不存在它可以据此生成一句自然的回复抱歉没找到这个订单请确认订单号是否正确而不是让整个对话崩掉。把失败也当成一种正常的工具输出这是多回合代理健壮性的关键。4. 多回合状态管理会话到底该存什么4.1 消息历史不是越长越好新手最容易犯的错是把所有历史消息原封不动地传给模型。聊到第 20 轮prompt 里塞了几十条消息token 成本飙升不说模型还会因为上下文太长而抓不住重点。我的经验是消息历史要分层管理。第一层是最近 N 轮原文通常保留最近 6 到 10 轮保证对话连贯。第二层是摘要把更早的对话压缩成一段话比如用户此前咨询了订单 A123 的物流已告知预计 3 天送达。第三层是结构化状态比如当前用户 ID、正在处理的订单号、已确认的意图这些用字段存不占对话 token。Genkit 的 Session 机制允许你把这几层分开存。我一般用 Firestore 存一个 session 文档结构大致是interface SessionState { userId: string; recentMessages: Message[]; // 最近 N 轮 summary: string; // 早期对话摘要 slots: Recordstring, unknown; // 结构化槽位 updatedAt: number; }4.2 用 Firestore 存会话的读写模式Firestore 存会话有两个坑要注意。第一是并发写如果用户快速连发两条消息两个代理回合可能同时读写同一个 session 文档导致状态覆盖。解决办法是用 Firestore 的事务transaction包住读-改-写整个过程或者给 session 加一个版本号做乐观锁。第二是文档大小限制Firestore 单文档上限 1MB消息历史如果不做裁剪聊久了会超。所以recentMessages必须做长度控制超出就滚动进 summary。读写的典型模式是这样回合开始时读 session把 recentMessages 和 summary 拼成模型输入回合结束后把新的用户消息和模型回复追加进 recentMessages如果超过阈值就触发一次摘要压缩。摘要压缩本身也是一次模型调用可以异步做不阻塞用户看到回复。4.3 状态压缩的触发时机与策略摘要什么时候触发我的策略是按消息条数触发而不是按时间。比如 recentMessages 超过 12 条时把最老的 6 条压缩进 summary。这样能保证 token 消耗是可预测的不会因为用户聊得久就无限增长。压缩的 prompt 也有讲究。不要简单说总结这段对话而要明确告诉模型保留用户提到的订单号、金额、已确认的诉求丢弃寒暄和重复内容。我实测下来带明确保留项的摘要 prompt压缩后的信息密度高很多后续回合模型引用历史时更准。注意摘要压缩会丢失细节所以关键的结构化信息订单号、用户 ID一定要单独存进 slots 字段不能只依赖摘要。摘要只负责语气和上下文连贯不负责精确数据。5. 代理回合的推进逻辑与工具编排5.1 一个回合从输入到输出的完整链路一个代理回合的完整链路是这样的接收用户输入 → 加载 session → 组装模型输入系统提示 摘要 最近消息 工具定义→ 调用模型 → 判断模型是否要求调用工具 → 如果有执行工具并把结果回灌给模型 → 模型再次生成 → 直到模型不再要求调用工具输出最终回复 → 更新 session。这个循环就是所谓的agent loop。Genkit 的generate配合工具定义会自动处理模型要求调用工具这一步你不需要手写循环判断。但你要理解它内部在做什么否则出问题时无从下手。关键点是每次工具调用都会产生一轮额外的模型请求所以一个回合可能对应多次模型调用成本和延迟都要按这个来估算。5.2 多工具并行调用与顺序依赖有些场景下模型会一次性要求调用多个工具比如用户问我的订单到哪了顺便帮我算下退款能退多少。这两个操作互不依赖可以并行执行。Genkit 支持模型返回多个工具调用请求框架会并发执行它们然后把所有结果一起回灌。这比串行执行快很多。但要注意有依赖关系的工具不能并行。比如先查订单再根据订单金额算退款第二步依赖第一步的结果。这种情况模型通常会分两轮调用第一轮查订单拿到结果后第二轮算退款。你不需要特殊处理但要意识到这种场景下延迟会叠加。如果依赖关系很明确我有时会把它们合并成一个工具内部串行执行减少模型往返次数。5.3 防止代理陷入无限工具调用循环代理循环最危险的情况是死循环模型反复调用同一个工具或者两个工具互相触发永远不输出最终回复。我遇到过模型因为工具返回格式不符合预期反复重试同一个调用的情况token 哗哗地烧。防护手段有三层。第一层是设置最大工具调用轮数比如一个回合最多允许 5 次工具调用超过就强制让模型基于现有信息作答。第二层是工具返回格式要稳定别这次返回对象、下次返回字符串模型会困惑。第三层是在系统提示里明确如果工具多次返回相同结果请直接告知用户当前无法完成给模型一个退出路径。const MAX_TOOL_ROUNDS 5; // 在 agent loop 中计数超过阈值时移除工具定义强制模型直接生成文本这个阈值不是拍脑袋定的。我一般观察正常业务下单个回合的工具调用次数分布取 P99 再往上加一点。客服场景通常 1 到 3 次复杂的数据分析场景可能到 5 到 8 次。6. 流式输出与前端体验的衔接6.1 为什么多回合代理必须做流式多回合代理因为要跑工具调用首字节延迟天然比单次调用高。如果还等整个回复生成完再一次性返回用户会盯着空白屏幕好几秒体验极差。流式输出streaming能让用户第一时间看到模型开始打字感知延迟大幅降低。Genkit 提供generateStream返回一个流对象你可以逐块消费。在 Cloud Functions 里通常配合 Server-Sent Events 或者 Firebase 的实时能力把流推给前端。这里有个细节工具调用阶段是没有文本输出的所以流式开始后可能先有一段静默期等工具执行完模型才开始吐字。我一般会在这段静默期给前端发一个正在查询中的状态事件让用户知道系统在干活而不是卡住了。6.2 流式过程中工具调用的处理流式模式下工具调用的处理和普通模式略有不同。模型可能先输出一段文本比如我来帮你查一下然后触发工具调用工具执行完再继续输出。前端需要能处理这种文本-工具-文本交替的流。我的做法是给流里的每个 chunk 打上类型标记text类型的直接追加到气泡tool_call类型的显示一个加载指示器tool_result类型的收起指示器。这样用户看到的效果是AI 先说我来帮你查一下然后出现一个查询订单中...的小提示几秒后提示消失AI 继续输出查询结果。整个过程是连贯的不会让用户觉得系统在发呆。6.3 前端如何消费代理的流式响应前端消费流式响应核心是增量渲染 状态机。不要每收到一个 chunk 就重建整个消息列表那样会闪烁。正确做法是维护一个当前正在生成的消息对象chunk 来了就 append 到它的内容里只重渲染这一条。// 伪代码示意 let currentMessage { role: assistant, content: , status: streaming }; for await (const chunk of stream) { if (chunk.type text) { currentMessage.content chunk.text; renderMessage(currentMessage); } else if (chunk.type tool_call) { currentMessage.status tool_running; renderMessage(currentMessage); } } currentMessage.status done;状态机的好处是无论流里出现什么顺序的事件前端都能正确反映当前处于哪个阶段。我见过有的实现把工具调用和文本输出当成两条独立的流处理结果顺序一乱就显示错位用状态机就不会有这个问题。7. 部署到 Firebase 与生产环境的注意事项7.1 Cloud Functions 的冷启动与超时配置把代理部署成 Cloud Function第一个要面对的就是冷启动。代理逻辑本身要初始化 Genkit、加载工具定义、连数据库冷启动可能好几秒。缓解办法是设置最小实例数为 1让至少一个实例常驻代价是持续计费。如果预算敏感可以只在业务高峰时段开最小实例。超时配置也很关键。多回合代理一个回合可能跑好几次模型调用加工具执行默认超时时间往往不够。我一般把函数超时设到 60 秒以上同时在前端做超时兜底超过一定时间给用户一个处理中请稍候的提示而不是让请求一直挂着。7.2 密钥与模型配置的安全管理模型 API 密钥绝对不能硬编码进代码也不能提交到仓库。Firebase 的做法是用 Cloud Functions 的环境变量或者 Secret Manager。本地开发时用.env文件并且把.env加进.gitignore。# 部署时设置环境变量 firebase functions:secrets:set GOOGLE_GENAI_API_KEY另外模型配置用哪个模型、温度多少、最大 token 数建议抽成配置文件不同环境用不同配置。开发环境可以用便宜的小模型快速迭代生产环境再切到能力更强的模型。这样调试成本能降不少。7.3 监控代理质量日志、追踪与评估代理上线后你怎么知道它表现好不好光看错误率是不够的因为代理答非所问不会报错。我的做法是三层监控。第一层是结构化日志每个回合记录用户输入、调用了哪些工具、工具耗时、最终回复方便事后复盘。第二层是追踪tracingGenkit 自带追踪能力能看到一个回合内每次模型调用和工具调用的耗时定位性能瓶颈。第三层是离线评估定期抽一批真实对话用另一个模型或者人工打分看回复质量有没有下降。我特别想强调追踪的价值。有一次线上反馈代理变慢看错误率一切正常最后靠追踪发现是某个工具的外部接口响应时间从 200ms 涨到了 3 秒导致整个回合延迟翻倍。没有追踪这种问题很难定位。8. 我在实际项目里踩过的几个真实坑8.1 工具 schema 过于宽松导致模型乱填参数早期我定义工具参数时图省事用了z.string()不加任何约束。结果模型经常传一些格式奇怪的订单号比如带空格、带中文、长度不对。后来我给关键参数加了正则约束和长度限制模型填错的概率大幅下降。schema 越严格模型越不容易犯错因为它在生成参数时会受到约束提示。8.2 会话状态在并发下的覆盖问题前面提过并发写的问题我实际遇到过用户手速快连发两条消息两个回合同时读到同一个旧 session各自改完写回后写的把先写的覆盖了导致第一条消息的上下文丢失。修复方案是用 Firestore 事务读的时候拿版本号写的时候校验版本号不一致就重试。这个坑不遇到不知道一遇到就是偶发的、难复现的 bug。8.3 模型对工具返回结果的过度解读有次工具返回了一个包含内部字段的对象模型把内部字段也念给用户听了比如把数据库的_internal_flag字段暴露出来。解决办法是工具返回给模型的数据要经过裁剪只返回用户该知道的信息。别图省事把整个数据库文档丢给模型它分不清哪些是内部字段。8.4 摘要压缩丢失关键信息的补救摘要压缩偶尔会丢关键信息比如把订单号压缩没了。我的补救办法是在 slots 里冗余存一份关键实体并且在系统提示里明确用户提到的订单号以 slots 中的为准。这样即使摘要丢了模型也能从结构化字段里拿到准确数据。关键信息永远不要只存在一个地方这是我在状态管理上最深的体会。9. 从单代理到多代理协作的扩展思路9.1 什么时候该拆成多个代理单个代理能处理的事情是有限的。当你的业务涉及多个差异很大的领域比如售前咨询和售后工单把它们塞进一个代理会导致工具列表过长、系统提示臃肿、模型决策变慢。这时候就该考虑拆成多个专职代理用一个路由代理根据用户意图分发。判断标准很简单如果两个领域的工具几乎没有交集系统提示的侧重点完全不同就该拆。反之如果工具高度重叠拆了反而增加协调成本。9.2 代理之间如何传递上下文多代理协作最难的是上下文传递。路由代理判断出这是售后问题转给售后代理时要把用户身份、历史摘要、相关槽位一起传过去。我的做法是定义一个统一的AgentContext结构所有代理都从这个结构里读上下文避免每个代理各存一套。interface AgentContext { userId: string; summary: string; slots: Recordstring, unknown; recentMessages: Message[]; }代理之间不直接调用而是通过共享的 context 和 session 来协作。这样每个代理都是无状态的状态统一由 session 层管理扩展和测试都简单。9.3 路由代理的实现要点路由代理本身也是一个代理只不过它的工具是切换到某个专职代理。实现上我一般让路由代理输出一个结构化的意图标签而不是让它直接生成回复。拿到标签后代码层再决定调用哪个专职代理。这样路由逻辑是可控的、可测试的不会因为模型一时抽风把用户导到错误的代理。路由的准确率靠两点保证一是意图标签的定义要互斥且覆盖全二是给路由代理的输入要包含足够的上下文比如最近几轮消息不能只看当前这一句。10. 性能与成本优化的几个实操手段10.1 模型分级不同环节用不同模型不是所有环节都需要最强的模型。路由判断、摘要压缩这类任务用便宜的小模型完全够用只有最终面向用户的回复生成才需要能力强的模型。我实测下来把摘要压缩换成小模型成本能降一大截质量几乎没影响。Genkit 允许你在不同 Flow 里配置不同的模型所以这种分级很容易实现。关键是要分别评估每个环节的质量别一刀切全用小模型也别全用大模型浪费钱。10.2 工具结果的缓存策略有些工具查询的数据变化不频繁比如商品详情、运费规则可以加缓存。缓存能显著减少工具执行时间进而降低整个回合的延迟。我用的是内存缓存加 TTL简单场景够用如果多实例部署就换成 Redis 之类的共享缓存。要注意缓存失效的时机。订单状态这种实时性要求高的数据不能缓存商品详情这种可以缓存几分钟。缓存策略要按工具逐个定不能统一处理。10.3 token 消耗的可观测性成本优化的前提是能看见成本。我给每个回合都记录了 token 消耗按用户、按代理、按工具维度聚合。这样能发现异常比如某个用户的对话 token 消耗特别高可能是陷入了某种循环某个工具的调用特别频繁可能是描述写得让模型误解了。有了这些数据优化才有方向。我一般每周看一次 token 消耗的分布找出 top 消耗的场景针对性优化比盲目调 prompt 有效得多。11. 写在最后的一点个人体会做多回合 AI 代理这一年多我最大的感受是框架能帮你省掉重复劳动但省不掉对业务的理解。Genkit 的代理 API 把状态管理、工具编排、流式输出这些脏活累活封装好了但工具该怎么定义状态该存什么循环该怎么兜底这些决策还是得你自己根据业务来定。我见过有人把框架用得很溜但代理一上线就各种答非所问问题往往不在框架而在于工具描述写得含糊、状态管理设计得粗糙。另一个体会是可观测性要尽早做。代理的行为不像传统程序那样确定同样的输入可能因为模型采样而给出不同输出。没有日志和追踪你根本不知道线上发生了什么。我现在的习惯是代理功能还没上线监控和追踪就先搭好这样一有问题立刻能定位。最后分享一个我常用的调试技巧把每个回合的完整输入系统提示 摘要 消息 工具定义和输出都打到日志里用一个开关控制。调试时打开生产时关掉。这样当代理行为异常时你能看到它到底看到了什么很多问题一眼就能看出来——比如工具描述被截断了、摘要压缩把关键信息弄丢了、消息顺序乱了。这个习惯帮我省了无数排查时间。