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提供代理和工具相关的 API,@genkit-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和moduleResolution=node10会被移除,如果你用的是较新的 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 Map<string, SessionState>(); private maxSize = 100; async get(sessionId: string): Promise<SessionState | 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): Promise<void> { 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[]): Promise<Message[]> { 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 withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> { return Promise.race([ promise, new Promise<T>((_, 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 轮要高得多,因为你能快速定位是哪一轮开始跑偏的。