
1. 为什么我要用 Genkit 代理 API 重写多回合 AI 代理多回合 AI 代理这件事我在过去一年里用不同方案反复折腾过。最早是手写状态机把每一轮对话的上下文塞进数组里再拼成 prompt 丢给模型后来换成函数调用让模型自己决定调哪个工具再后来发现真正难的不是让模型说话而是让模型在多轮里记住自己做过什么、该做什么、不该做什么。Genkit 的代理 API 就是在这个节点进入我的视野的。Genkit 是 Firebase 团队开源的一套 AI 应用开发框架核心语言是 TypeScript天然和 Firebase 生态打通。它的代理 API 并不是一个简单的对话封装而是一套围绕多回合工具调用循环设计的抽象你定义工具、定义代理、定义终止条件剩下的循环调度、消息拼接、工具结果回填框架帮你处理。换句话说它把ReAct 式的推理-行动-观察循环做成了可配置的运行时。这篇文章适合三类人看一是已经在用 TypeScript 写 AI 应用、但被多轮状态管理折磨过的开发者二是想从零搭一个能调工具、能多轮追问的 AI 代理、又不想自己造轮子的工程师三是已经在用 Firebase、想看看 Genkit 能不能接进现有项目的人。我会把代理 API 的核心机制、工具定义方式、多回合循环的控制点、以及我在实际项目里踩过的坑全部摊开讲。需要提前说明的是Genkit 的版本迭代比较快代理 API 在不同版本里命名和参数有过调整。我下面讲的内容基于我实际使用的稳定版本如果你用的是更新的版本建议先对照官方文档确认 API 签名但核心思路是一致的。2. Genkit 代理 API 的核心机制拆解2.1 代理和普通对话流的本质区别很多人第一次接触 Genkit 的代理 API会把它和generate或者chat混为一谈。我一开始也是这么想的直到我把一个需要连续调用三次工具的任务跑崩了才意识到区别在哪。普通的generate是一次性的你给一个 prompt它返回一个结果结束。chat稍微好一点它维护一个消息历史但每一轮仍然是用户说一句、模型回一句的单步交互。而代理 API 的核心是自主循环模型可以在一次调用里连续发起多个工具调用每次拿到工具结果后继续推理直到它认为任务完成或者触发终止条件。这个区别用生活场景类比就很清楚。普通对话像你去窗口办事说一句、对方回一句你得自己判断下一步该干嘛。代理则像你雇了一个助理你只说帮我把这件事办完助理自己决定先查资料、再打电话、再填表中间不需要你逐步指挥。Genkit 代理 API 实现这个循环的关键在于三个东西工具注册表、消息轨迹、终止判定。工具注册表决定了代理能做什么消息轨迹记录了代理做过什么包括每一次工具调用的入参和返回终止判定决定了循环什么时候停。这三者组合起来才构成一个真正的多回合代理。2.2 工具定义代理的手脚怎么接工具是代理能力的边界。在 Genkit 里定义工具用的是defineTool需要提供名称、描述、输入 schema、输出 schema以及执行函数。这里有个细节很多人会忽略描述字段不是给人看的是给模型看的。模型根据描述判断什么时候该调这个工具描述写得含糊模型就会乱调或者不调。我举个实际例子。我做过一个查询订单状态的代理工具描述一开始写的是查询订单结果模型在用户问我的包裹到哪了的时候经常不调这个工具因为它不确定包裹和订单是不是一回事。后来我把描述改成根据订单号查询订单的当前物流状态和预计送达时间调用准确率立刻上来了。输入输出的 schema 用 Zod 定义这是 Genkit 和 TypeScript 结合最舒服的地方。Zod 的 schema 既能做运行时校验又能推导出 TypeScript 类型模型返回的参数如果不符合 schema框架会直接报错而不是让脏数据流进你的业务逻辑。这一点在多回合场景里特别重要因为代理可能连续调十几次工具任何一次参数错误都可能让整个循环跑偏。import { defineTool } from genkit-ai/ai; import { z } from zod; export const queryOrderTool defineTool( { name: queryOrder, description: 根据订单号查询订单的当前物流状态和预计送达时间, inputSchema: z.object({ orderId: z.string().describe(订单号通常是 12 位数字), }), outputSchema: z.object({ status: z.string(), estimatedDelivery: z.string(), }), }, async (input) { const order await fetchOrderFromDB(input.orderId); return { status: order.status, estimatedDelivery: order.eta, }; } );注意describe的用法。给每个字段加描述模型在生成参数时会参考这些描述尤其是当字段格式有约定时比如12 位数字写清楚能显著降低参数错误率。2.3 多回合循环的控制点在哪里代理 API 的循环不是无限跑的它有几个控制点理解这些控制点是你能否驾驭它的关键。第一个控制点是最大迭代次数。框架通常会有一个默认上限防止代理陷入死循环。我建议显式设置这个值而不是依赖默认。原因很简单不同任务的合理迭代次数差别很大查订单可能两轮就够做数据分析可能要十几轮。默认值要么太保守导致任务没跑完就停要么太宽松导致异常时浪费大量 token。第二个控制点是终止工具。你可以定义一个特殊工具让模型在认为任务完成时调用它框架收到这个调用就结束循环。这比单纯靠模型不再调工具来判断更可靠因为模型有时候会输出一段总结文字但不调工具这时候你无法区分它是完成了还是在等你补充信息。第三个控制点是工具执行异常的处理策略。工具调用失败时是把错误信息回填给模型让它重试还是直接中断循环这两种策略适用不同场景。查询类工具失败回填错误让模型换个参数重试是合理的但如果是写操作失败直接中断可能更安全避免模型反复尝试造成副作用。const agent defineAgent({ name: orderAssistant, tools: [queryOrderTool, cancelOrderTool, finishTool], maxIterations: 8, systemPrompt: 你是一个订单助手帮助用户查询和取消订单。任务完成后调用 finish 工具。, });这段配置里maxIterations和finishTool就是两个显式控制点。我实测下来把这两个都设好代理跑飞的几率能降一大半。3. 从零搭一个多回合代理的完整实操3.1 环境准备和依赖安装先说环境。Genkit 是 TypeScript 优先的所以你需要一个 Node.js 环境我用的版本是 Node 20 LTS。包管理用 npm 或者 pnpm 都行我个人偏好 pnpm装依赖快、磁盘占用小。初始化项目之后装这几个核心包genkit是主包genkit-ai/ai提供代理和工具相关的 APIgenkit-ai/googleai或者你用的其他模型插件提供模型接入zod做 schema 校验。如果你要接 Firebase再加genkit-ai/firebase。pnpm init pnpm add genkit genkit-ai/ai genkit-ai/googleai zod pnpm add -D typescript tsx types/node这里有个 TypeScript 配置的坑要提醒。最近 TypeScript 7.0 的弃用警告里提到了baseUrl和moduleResolutionnode10会被移除如果你用的是较新的 TS 版本建议直接用moduleResolution: bundler或者node16别再用node10。我一开始没注意构建时一堆警告虽然不影响运行但看着烦。{ compilerOptions: { target: ES2022, module: ESNext, moduleResolution: bundler, strict: true, esModuleInterop: true, skipLibCheck: true, outDir: dist } }strict: true建议开着。Genkit 的类型定义比较完整开着 strict 能帮你在编译期发现很多 schema 不匹配的问题比运行时才发现要省事得多。3.2 定义工具集让代理有活可干工具集的设计直接决定代理的能力上限。我的经验是工具粒度要适中太粗模型不知道怎么用太细模型要调很多次才能完成一件事token 消耗大且容易出错。以订单助手为例我设计了三个工具查询订单、取消订单、结束任务。查询和取消是业务工具结束是控制工具。每个工具的定义我都遵循同样的结构清晰的名称、具体的描述、严格的 schema、健壮的实现。import { defineTool } from genkit-ai/ai; import { z } from zod; export const cancelOrderTool defineTool( { name: cancelOrder, description: 取消指定订单。仅在用户明确要求取消且订单状态允许取消时调用, inputSchema: z.object({ orderId: z.string().describe(要取消的订单号), reason: z.string().optional().describe(取消原因可选), }), outputSchema: z.object({ success: z.boolean(), message: z.string(), }), }, async (input) { try { const result await cancelOrderInDB(input.orderId, input.reason); return { success: true, message: 订单 ${input.orderId} 已取消 }; } catch (err) { return { success: false, message: 取消失败${(err as Error).message} }; } } ); export const finishTool defineTool( { name: finish, description: 当任务完成或无法继续时调用用于结束对话, inputSchema: z.object({ summary: z.string().describe(本次任务的简要总结), }), outputSchema: z.object({ done: z.boolean() }), }, async () ({ done: true }) );注意cancelOrder的描述里我加了仅在用户明确要求取消且订单状态允许取消时调用。这是为了防止模型自作主张取消订单。多回合代理里模型有时候会过度热心用户只是问了一句这个订单还能取消吗它就直接调取消工具了。描述里加约束条件能有效抑制这种行为。3.3 组装代理并跑通第一轮工具定义好之后组装代理就是配置的事。把工具数组传进去设置系统提示词指定模型设置迭代上限。import { genkit } from genkit; import { googleAI } from genkit-ai/googleai; const ai genkit({ plugins: [googleAI()], model: googleai/gemini-1.5-flash, }); const orderAgent ai.defineAgent({ name: orderAssistant, system: 你是一个订单助手。你可以查询订单状态、取消订单。 用户的问题如果涉及订单先查询再回答。 取消订单前必须确认用户意图。 任务完成后调用 finish 工具。, tools: [queryOrderTool, cancelOrderTool, finishTool], maxIterations: 8, });系统提示词我写得比较具体把行为约束都列出来了。这里的原则是能在提示词里说清楚的规则就不要指望模型自己悟。多回合场景里模型每一步都要做决策规则越明确跑偏概率越低。跑第一轮测试的时候我建议从最简单的场景开始用户问帮我查一下订单 123456789012 的状态。观察代理的完整轨迹它调了几次工具、每次的参数是什么、最后怎么结束的。Genkit 提供了轨迹查看的方式把每一步的消息都打出来这是调试多回合代理最重要的手段。3.4 观察轨迹多回合代理的调试核心多回合代理最难调的地方在于它不像单步调用那样一眼能看出问题。一个任务跑完中间可能经历了五六次工具调用你得知道每一步发生了什么才能定位问题。我的做法是在开发阶段把完整轨迹打出来包括每一轮模型输出的文本、发起的工具调用、工具返回的结果。Genkit 的响应对象里包含这些信息遍历打印即可。const response await orderAgent.run({ messages: [{ role: user, content: 帮我查一下订单 123456789012 的状态 }], }); for (const msg of response.messages) { console.log(---, msg.role); if (msg.content) console.log(msg.content); if (msg.toolRequests) { for (const tr of msg.toolRequests) { console.log(调用工具:, tr.toolName, 参数:, JSON.stringify(tr.input)); } } }我踩过的一个坑是早期我没打印工具返回只看了模型输出结果发现模型一直在重复调同一个工具我以为是模型的问题后来打印工具返回才发现是工具返回的数据格式和 schema 不匹配模型拿到的是一堆错误信息只能反复重试。轨迹要打全模型输出、工具调用、工具返回一个都不能少。4. 多回合代理的进阶控制与状态管理4.1 会话状态怎么存内存、Firestore 还是自定义多回合代理的多回合有两种含义一种是单次任务内的多轮工具调用另一种是跨用户消息的多轮对话。前者由代理 API 的循环机制处理后者需要你自己管理会话状态。会话状态存哪里是个需要认真做的决定。最简单的是存内存用一个 Map 以会话 ID 为键。这在开发阶段够用但一上生产就废了服务重启状态丢失多实例部署状态不共享。再进一步是用 Firestore。Genkit 和 Firebase 生态打通Firestore 存会话状态很自然。每个会话一个文档消息历史作为数组字段。好处是持久化、可查询、多实例共享。坏处是每次读写都有网络开销高频对话场景下延迟明显。我的选择是混合方案热会话存内存加定期落盘冷会话从 Firestore 加载。具体做法是维护一个 LRU 缓存最近活跃的会话在内存里超过一定时间没活动的会话写回 Firestore 并从内存移除。这样既保证了活跃会话的响应速度又保证了状态不丢。interface SessionState { sessionId: string; messages: Message[]; lastActive: number; } class SessionStore { private cache new Mapstring, SessionState(); private maxSize 100; async get(sessionId: string): PromiseSessionState | null { if (this.cache.has(sessionId)) { return this.cache.get(sessionId)!; } const doc await firestore.collection(sessions).doc(sessionId).get(); if (!doc.exists) return null; const state doc.data() as SessionState; this.cache.set(sessionId, state); return state; } async save(state: SessionState): Promisevoid { state.lastActive Date.now(); this.cache.set(state.sessionId, state); if (this.cache.size this.maxSize) { const oldest [...this.cache.entries()] .sort((a, b) a[1].lastActive - b[1].lastActive)[0]; await firestore.collection(sessions).doc(oldest[0]).set(oldest[1]); this.cache.delete(oldest[0]); } } }这个实现不复杂但解决了实际问题。要注意的是写回 Firestore 是异步的如果服务在写回前崩溃这部分状态会丢。对状态一致性要求高的场景得改成同步写或者加消息队列。4.2 上下文窗口管理别让历史撑爆 token多回合对话跑久了消息历史会越来越长最终撑爆模型的上下文窗口。这个问题在代理场景里更严重因为一次任务内就有多轮工具调用消息增长比普通对话快得多。我的处理策略是分层裁剪。最近 N 轮消息完整保留更早的消息做摘要压缩。摘要不是简单截断而是让模型把早期对话压缩成一段要点保留关键信息比如用户已经确认过的订单号、已经执行过的操作丢弃冗余的寒暄和中间推理过程。async function compressHistory(messages: Message[]): PromiseMessage[] { const RECENT_COUNT 6; if (messages.length RECENT_COUNT) return messages; const recent messages.slice(-RECENT_COUNT); const older messages.slice(0, -RECENT_COUNT); const summaryResponse await ai.generate({ prompt: 把以下对话压缩成要点保留订单号、已执行操作、用户明确表达的需求 ${older.map(m ${m.role}: ${m.content}).join(\n)}, }); return [ { role: system, content: 早期对话摘要${summaryResponse.text} }, ...recent, ]; }这里有个细节摘要里一定要保留已执行操作。多回合代理最怕的就是重复执行比如用户已经取消过的订单代理因为历史被压缩忘了又取消一次。把已执行操作写进摘要能有效避免这个问题。4.3 工具调用的幂等性设计说到重复执行就不得不提幂等性。多回合代理因为要循环调用工具重复调用的概率比单步调用高得多。模型可能因为网络抖动重试可能因为没理解工具返回而重试也可能因为上下文压缩丢失信息而重试。我的做法是给所有写操作工具加幂等键。幂等键由会话 ID 加操作类型加关键参数组成工具执行前先查这个键有没有执行过执行过就直接返回上次的结果。async function cancelOrderIdempotent( sessionId: string, orderId: string, reason?: string ) { const idempotencyKey cancel:${sessionId}:${orderId}; const existing await firestore .collection(idempotency) .doc(idempotencyKey) .get(); if (existing.exists) { return existing.data()!.result; } const result await cancelOrderInDB(orderId, reason); await firestore.collection(idempotency).doc(idempotencyKey).set({ result, createdAt: Date.now(), }); return result; }幂等键的过期时间也要考虑。设太长存储压力大设太短起不到防重作用。我的经验是设 24 小时覆盖绝大多数重试场景。5. 常见问题与排查技巧实录5.1 代理陷入死循环怎么办死循环是多回合代理最典型的问题。表现是代理反复调用同一个工具或者在不同工具之间来回横跳永远不结束。排查思路分三步。第一步看工具返回。如果工具一直返回错误模型会不断重试这时候要修的是工具本身。第二步看系统提示词。如果提示词里没有明确的终止条件模型可能不知道什么时候该停。第三步看迭代上限。如果上限设得太高即使模型在合理重试也会跑很久。我的解决组合拳是工具返回错误时在错误信息里明确告诉模型这个错误重试无用请换方案或结束系统提示词里写清楚什么情况下调用 finish迭代上限设一个合理值我一般设 8 到 10。注意不要用重试三次就放弃这种硬编码逻辑去限制模型因为模型看不到你的代码逻辑它只会觉得工具还能调。把限制写进工具返回或提示词里模型才能感知到。5.2 工具参数总是传错参数传错通常有两个原因schema 描述不清或者模型对参数格式理解有偏差。我遇到过一个典型案例日期参数。schema 里写的是z.string()模型有时候传 2024-01-15有时候传 1月15日有时候传 下周一。工具拿到这些五花八门的格式直接崩。解决办法是在 schema 里用更严格的约束比如z.string().regex(/^\d{4}-\d{2}-\d{2}$/)并在 describe 里写明格式要求。如果模型还是传错可以在系统提示词里再强调一遍。双保险下来参数错误率能降到很低。问题现象可能原因排查动作解决方式参数格式不一致schema 约束太松检查 schema 定义加 regex 或 enum 约束参数缺失字段非必填但业务需要检查 required 字段改为必填或加默认值参数值超出范围没有范围校验检查数值约束加 min/max 约束参数语义错误describe 描述不清检查字段描述补充格式和语义说明5.3 模型不调用工具直接回答有时候模型会跳过工具直接凭自己的知识回答。比如用户问订单状态模型直接编一个您的订单正在配送中根本没调查询工具。这个问题在系统提示词里加约束能解决大半。我通常写涉及订单状态、订单操作的问题必须先调用相应工具获取真实数据禁止凭猜测回答。再加一条如果用户问题涉及具体订单但未提供订单号先询问订单号不要猜测。如果加了提示词还是不行可能是模型能力问题。不同模型对工具调用的遵循度差别很大我实测下来能力强的模型在工具调用上更可靠。如果预算允许换一个工具调用能力更强的模型是最直接的解法。5.4 多轮对话中代理失忆跨消息的多轮对话里代理经常忘记前面说过什么。用户第一轮说了订单号第二轮问那这个订单能取消吗代理反问请问是哪个订单。这个问题的根源是会话状态没接上。检查两点一是每轮请求有没有把历史消息带上二是历史消息有没有被过度压缩。我见过有人为了省 token每轮只带最近两条消息结果代理完全没有上下文。我的建议是最近 6 到 10 轮消息完整保留更早的做摘要。摘要里必须包含用户提供过的关键实体订单号、用户 ID 等和已执行的操作。这样既控制了 token又保住了关键上下文。5.5 工具执行超时拖垮整个循环工具执行慢是另一个常见坑。一个查询工具如果卡了 30 秒整个代理循环就卡在那里用户等得花儿都谢了。给每个工具加超时是必须的。我的做法是在工具执行函数里包一层 Promise.race超时就返回一个明确的错误信息让模型知道这个工具暂时不可用可以选择换方案或者结束。function withTimeoutT(promise: PromiseT, ms: number): PromiseT { return Promise.race([ promise, new PromiseT((_, reject) setTimeout(() reject(new Error(工具执行超时${ms}ms)), ms) ), ]); }超时时间设多少要看工具类型。查询类工具我设 5 秒写操作设 10 秒。超过这个时间要么是下游服务有问题要么是网络有问题让模型等着也没意义。6. 生产环境部署与性能优化6.1 部署到 Firebase Functions 的注意事项Genkit 和 Firebase 是一家部署到 Firebase Functions 是最顺的路径。但有几个坑要注意。第一个是冷启动。Firebase Functions 冷启动时Genkit 的初始化和模型插件的加载都要时间第一次请求可能慢好几秒。我的做法是把初始化和代理定义放在模块顶层利用 Functions 的实例复用避免每次请求都重新初始化。第二个是超时限制。Firebase Functions 默认超时是 60 秒多回合代理跑满迭代次数可能超过这个时间。要么调高超时上限要么控制迭代次数和单次工具执行时间把总时长压进限制内。第三个是并发。Functions 实例是单线程的一个实例同时只能处理一个请求。高并发场景下要么调大实例数要么把代理逻辑拆到 Cloud Run 上。我做过一个中等流量的项目Functions 实例数设 10 基本够用。6.2 成本控制token 消耗怎么压下来多回合代理的 token 消耗比单步调用高一个数量级因为每一轮都要把完整历史发给模型。控制成本的核心就是控制历史长度和迭代次数。历史长度方面前面讲的摘要压缩是主要手段。我实测下来把历史从 20 轮压到 6 轮加摘要token 消耗能降 60% 左右而任务成功率基本不受影响。迭代次数方面除了设上限还可以优化工具设计。把多个小工具合并成一个复合工具能减少调用次数。比如查询订单和查询物流如果总是成对出现合并成一个查询订单及物流工具一次调用搞定省一轮循环。模型选择也影响成本。能力强的模型贵但调用次数少能力弱的模型便宜但可能要多调几次。我的经验是简单任务用便宜模型复杂任务用强模型按任务类型路由。6.3 监控与日志出了问题怎么查生产环境的代理必须有完善的日志。我记录这几类信息每次请求的会话 ID、用户输入、代理的完整轨迹、总耗时、token 消耗、最终结果。日志存 Firestore 或者 Cloud Logging 都行。关键是能按会话 ID 检索出问题时能快速还原整个对话过程。我遇到过一次线上问题用户投诉代理答非所问我按会话 ID 一查日志发现是工具返回的数据里有个字段名拼错了模型拿到的数据是 undefined只能瞎编。没有完整日志这种问题根本查不出来。监控指标我关注三个任务成功率、平均迭代次数、平均响应时间。任务成功率下降通常意味着工具或提示词出了问题迭代次数上升可能意味着模型开始犹豫响应时间上升要查工具执行和模型调用哪边慢了。7. 我在实际项目里的一些体会代理 API 这套东西用顺了之后确实能省很多事但它不是银弹。我最大的体会是代理的可靠性上限取决于你的工具设计和提示词质量而不是框架本身。框架帮你处理了循环调度但每一步决策的质量还是靠工具描述和系统提示词来保证。另一个体会是多回合代理不适合所有场景。如果任务步骤固定、逻辑明确写死流程比让模型自主决策更可靠也更便宜。代理适合的是那种步骤不固定、需要根据中间结果动态调整的场景比如客服、数据分析、复杂查询。用错场景代理只会给你添乱。最后分享一个小技巧开发阶段把maxIterations设小一点比如 3逼着自己把任务拆解清楚确保核心流程在少数几轮内能跑通。等核心流程稳了再逐步放开迭代上限去覆盖边缘情况。这样调试效率比一上来就设 10 轮要高得多因为你能快速定位是哪一轮开始跑偏的。