多回合代理这件事,真正上手做过的人都知道,难点从来不在"让模型回一句话",而在于让它在多轮交互里记住上下文、按需调用工具、把中间状态存下来,还要在下一轮里接着用。Genkit 的代理 API 就是冲着这个场景来的,它把"多回合"这件事从你自己手写状态机,变成了框架层面能托底的能力。这篇内容我打算把用 Genkit 代理 API 搭一个多回合 AI 代理的完整思路拆开讲,从它到底解决了什么问题、核心概念怎么理解,到 TypeScript 项目里怎么落地、Firestore 怎么接、工具怎么挂、状态怎么续,再到实测中容易翻车的地方。适合已经写过简单 LLM 调用、想往"能记住事、能干活"的代理方向走的人,也适合正在用 TypeScript 做 AI 应用、纠结要不要引入框架的开发者。
1. 先搞清楚 Genkit 代理 API 到底在解决什么
1.1 单次调用和多回合代理的本质差距
大部分人第一次接触大模型,写的都是这种代码:拼一个 prompt,发一次请求,拿一次回复,结束。这种模式在问答、翻译、总结这类"一问一答"的场景里够用,但一旦你要做的是"帮用户订一张明天下午的机票,如果没票就换一班,顺便把行程加到日历里",单次调用立刻就不够看了。因为这件事天然需要多步:先理解意图,再查航班,发现没票要重新决策,最后执行写入。每一步的结果都要影响下一步,这就是多回合。
多回合的核心矛盾在于状态。模型本身是无状态的,你每次调用它,它都当自己是第一次见你。所谓"记住上下文",本质是你把历史消息重新塞回去。手动做这件事,短对话还行,轮次一多,消息数组越来越长,你还得自己判断哪些该留、哪些该丢、工具调用的结果怎么拼回去。Genkit 代理 API 的价值,就是把这套"消息管理 + 工具循环 + 状态持久化"的脏活收敛到框架里,让你专注在业务逻辑上。
我自己的判断标准很简单:如果你的交互超过 3 轮,或者中间需要调用外部工具,或者需要跨会话记住用户偏好,那就别硬写裸调用,直接上代理框架。省下来的不是几十行代码,而是后面无穷无尽的边界 bug。
1.2 Genkit 的定位和它跟裸调 SDK 的区别
Genkit 是 Google 开源的一套 AI 应用开发框架,TypeScript 和 Go 都有支持。它最容易被误解的一点是:很多人以为它只是个"调模型的封装",其实它更像一个编排层。它管的是 flow(流程)、tool(工具)、retriever(检索)、prompt(提示模板)这些概念之间的关系,模型调用只是其中一环。
代理 API 是它在这套编排能力上加的一层,专门处理"带工具的对话循环"。跟裸调 SDK 比,它多做了几件事:一是把工具定义标准化,你写一个函数、给个 schema,它自动转成模型能理解的工具描述;二是自动处理"模型要求调用工具 → 执行工具 → 把结果喂回模型 → 模型继续"这个循环,你不用自己写 while;三是把对话状态抽象成可序列化的结构,方便你存到 Firestore 这类外部存储里。
这里有个认知上的坑要提前说:Genkit 不是要替代你的业务代码,它是把"模型和工具之间的胶水"标准化了。你的业务逻辑、数据库操作、权限校验,还是得自己写,只不过现在它们以"工具"的形式被代理调用。
1.3 什么场景适合用代理 API,什么场景别硬上
不是所有 AI 功能都值得上代理。我见过有人做一个"把这段中文翻译成英文"的功能,也硬套代理框架,结果引入一堆依赖,代码反而更复杂。判断标准我总结成三条:
- 需要多步决策:任务不能一次完成,中间要根据结果调整方向。比如客服工单处理、数据分析问答、行程规划。
- 需要调用外部能力:要查数据库、调 API、读写文件。工具调用是代理的核心价值。
- 需要跨轮次记忆:用户会在多轮里逐步补充信息,或者你需要在会话之间保留状态。
反过来,如果只是单轮生成、不需要外部数据、不需要记忆,那直接用 generate 类的单次调用就够了,别为了"用框架"而用框架。我踩过这个坑,一个纯文本改写功能套了代理,调试成本翻倍,最后又拆回单次调用。
2. 把核心概念理顺:代理、工具、会话状态三件套
2.1 代理不是"更聪明的模型",而是"带循环的编排器"
很多人对"代理"这个词有误解,以为它是某种更强的模型。不是的。代理是一个控制循环:它拿着当前对话历史,问模型"下一步干嘛",模型可能直接回答,也可能说"我要调用某个工具",代理就去执行工具,把结果追加到历史里,再问模型一次,直到模型给出最终回答或者达到轮次上限。
理解这一点很关键,因为它决定了你调试时的思路。代理出问题,往往不是模型笨,而是循环里的某一环断了:工具描述模型没看懂、工具执行报错没被正确处理、历史消息拼错了、轮次上限设太低了。把代理当成一个"while 循环 + 消息数组"来看,问题就好定位多了。
Genkit 里定义代理通常用defineTool定义工具,用 flow 或者专门的代理构造来组织循环。工具的定义包含名字、描述、输入 schema、输出 schema 和一个执行函数。描述这块特别重要,模型就是靠描述来判断"什么时候该用这个工具"的,写得含糊,模型就乱调或者不调。
2.2 工具定义的质量直接决定代理的智商
我做过一个对比实验:同一个代理,工具描述写得随便 vs 写得精细,任务成功率差了一大截。工具描述要回答三个问题:这个工具干什么、什么时候用、输入要什么格式。举个例子,一个查订单的工具,描述写"查询订单"就太弱了,写成"根据订单号查询订单的当前状态和物流信息,当用户询问订单进度、物流、是否发货时使用,输入为订单号字符串"就清楚多了。
输入 schema 也不能马虎。Genkit 用 Zod 这类 schema 库来定义工具输入输出,好处是模型拿到的工具描述里会带上字段说明,而且执行前框架会做校验。我建议每个字段都写清楚含义和格式,尤其是枚举值、日期格式这种容易出错的。schema 写得好,等于给模型加了一层护栏。
还有一个经验:工具粒度要适中。太粗,一个工具干十件事,模型不知道该传什么参数;太细,几十个工具,模型选择困难,还容易串。我一般控制在单个代理 5 到 15 个工具之间,超过就考虑拆成多个代理或者做工具分组。
2.3 会话状态为什么必须外置到 Firestore
代理的对话历史会随着轮次增长,如果只放在内存里,服务一重启就没了,多实例部署时还会出现"这轮请求打到 A 实例、下轮打到 B 实例,历史对不上"的问题。所以生产环境里,会话状态必须外置。
Firestore 是个自然的选择,尤其你在用 Google 生态的话。它有几个好处:文档模型天然适合存"一个会话一个文档"、支持实时更新、有现成的 SDK、按量计费对小规模应用友好。存的内容一般包括:会话 ID、消息历史数组、当前状态标记、创建和更新时间、以及你自定义的元数据(比如用户 ID、代理类型)。
这里有个设计决策要提前想清楚:历史消息是全存还是只存摘要。全存简单,但轮次多了文档会变大,而且每次都要把全部历史发给模型,token 成本高。只存摘要省 token,但会丢细节。我的做法是折中:保留最近 N 轮完整消息,更早的做摘要压缩,N 一般取 10 到 20。这个策略后面在状态管理那节会展开讲。
3. TypeScript 项目里把代理跑起来
3.1 环境准备和依赖安装的取舍
先说环境。Node 版本建议 20 以上,TypeScript 用 5.x。这里插一句,最近社区里在讨论baseUrl和moduleResolution=node10这些选项被标记弃用、未来版本要移除的事。如果你是新项目,别再用node10这种老解析策略了,直接上bundler或者nodenext,省得以后迁移。baseUrl能不用就不用,路径别名用paths配合现代解析策略一样能做。
依赖方面,核心是 Genkit 本体和它的模型插件。模型插件取决于你用哪家模型,Genkit 支持多家。另外要装 Zod 做 schema 定义,装 Firestore 的 SDK 做状态存储。开发期建议装 tsx 或者用 Node 的原生 TS 支持来跑,别每次都编译。
npm install genkit @genkit-ai/googleai zod @google-cloud/firestore npm install -D typescript tsx @types/nodetsconfig 里我一般这么配关键几项:target用 ES2022,module用 NodeNext 或 ESNext,moduleResolution跟着 module 走,strict打开,skipLibCheck打开省时间。strict 一定要开,代理代码里类型错误往往对应着运行时 bug,别偷懒。
3.2 初始化 Genkit 和配置模型
初始化的代码不长,但有几个点容易忽略。第一是 API key 的管理,绝对不要硬编码在代码里,用环境变量。第二是插件的注册顺序,模型插件要在使用前注册好。第三是开发期的调试开关,Genkit 有开发者 UI 可以看每次调用的输入输出,调代理的时候非常有用,生产环境记得关掉。
import { genkit } from 'genkit'; import { googleAI } from '@genkit-ai/googleai'; export const ai = genkit({ plugins: [googleAI()], model: 'googleai/gemini-2.0-flash', });模型选择上,代理场景我更倾向用响应快、工具调用能力稳的模型,而不是一味追求最大最强的。因为代理是多轮循环,每轮都调一次模型,延迟会累加。一个中等规模但工具调用靠谱的模型,体验往往比一个超大但每轮慢好几秒的模型好。这个取舍要根据你的场景实测,别照搬别人的推荐。
3.3 定义第一个工具并挂到代理上
工具定义是代理的核心工作。我拿一个"查询订单状态"的工具举例,把关键点都标出来。
import { z } from 'zod'; import { ai } from './genkit-config'; export const queryOrderTool = ai.defineTool( { name: 'queryOrder', description: '根据订单号查询订单的当前状态、物流进度和预计送达时间。当用户询问订单进度、是否发货、物流信息时调用。', inputSchema: z.object({ orderId: z.string().describe('订单号,通常是 12 位数字字符串'), }), outputSchema: z.object({ status: z.string().describe('订单状态,如 pending/shipped/delivered'), logistics: z.string().describe('最新物流描述'), eta: z.string().describe('预计送达时间,ISO 格式'), }), }, async ({ orderId }) => { const order = await fetchOrderFromDB(orderId); if (!order) { return { status: 'not_found', logistics: '', eta: '' }; } return { status: order.status, logistics: order.latestLogistics, eta: order.eta, }; } );注意几个细节:描述里明确写了"什么时候调用",这是给模型看的;输入输出 schema 每个字段都有 describe,模型能理解字段含义;执行函数里对"查不到"的情况返回了结构化的结果而不是抛异常,因为抛异常会打断代理循环,返回结构化结果让模型自己决定怎么跟用户说,体验更好。
工具挂到代理上,一般是在构造代理或者 flow 的时候把工具数组传进去。Genkit 会自动把这些工具转成模型能理解的格式。挂的时候注意工具名要唯一,别跟内置的冲突。
4. 多回合的关键:状态怎么存、怎么续、怎么控
4.1 用 Firestore 存会话的文档结构设计
会话文档的设计直接影响后面查询和扩展的难易。我一般用这样的结构:文档 ID 就是会话 ID,字段包括 userId、agentType、messages 数组、summary 字段、createdAt、updatedAt、以及一个 metadata 对象放扩展信息。
messages 数组里每条消息包含 role(user/model/tool)、content、timestamp,如果是工具调用还要带 toolName 和 toolInput。summary 字段存早期对话的压缩摘要。这样设计的好处是:查一个会话就是读一个文档,简单直接;要按用户查所有会话,给 userId 建索引就行。
有个坑要提醒:Firestore 单个文档有大小限制,消息全堆一个文档里,长会话迟早会撞上限。所以要么定期归档老消息到子集合,要么就用前面说的摘要策略控制文档大小。我一般会在写入时检查消息数量,超过阈值就触发压缩。
4.2 消息历史的裁剪与摘要策略
这是多回合代理里最容易被低估的一环。直接把全部历史发给模型,短期没问题,长期一定出问题:token 成本飙升、模型被无关历史干扰、响应变慢。裁剪策略我实践下来比较稳的是"滑动窗口 + 摘要"。
具体做法:保留最近 N 轮完整消息(N 取 10 到 20),更早的消息用一次模型调用压缩成一段摘要,存到 summary 字段。每次构造请求时,把 summary 作为系统消息的一部分,加上最近 N 轮完整消息,一起发给模型。这样既保留了长期记忆的脉络,又控制了 token。
摘要的 prompt 也有讲究,别简单说"总结一下",要明确告诉模型"保留用户的关键需求、已确认的事实、未完成的任务,去掉寒暄和重复内容"。摘要质量直接影响代理的长期表现,值得多调几次。
4.3 轮次上限和循环终止条件
代理循环必须有终止条件,否则模型可能陷入"调工具 → 不满意 → 再调"的死循环,烧钱又慢。Genkit 的代理一般支持设置最大轮次,我建议设 5 到 10 轮。超过上限还没结束,就返回一个兜底回复,比如"这个问题比较复杂,我先记录一下,稍后给你详细答复"。
除了轮次上限,还要处理几种终止情况:模型给出最终回答(正常结束)、工具连续报错(应该中断并告知用户)、达到 token 预算上限。这些条件最好在代理配置里显式设置,别指望模型自己收敛。我见过没设上限的代理,遇到一个模糊问题,来回调了二十多次工具,账单直接起飞。
5. 实测中那些文档不会告诉你的坑
5.1 工具描述含糊导致模型乱调工具
这是我踩得最狠的一个坑。早期我写工具描述很随意,结果模型经常在不该调的时候调,或者该调 A 工具却调了 B。排查了半天才发现,问题出在描述上。模型判断用哪个工具,完全依赖描述文本,描述里没写清楚适用场景,它就只能猜。
解决办法前面提过,描述要包含"干什么、什么时候用、输入格式"。另外一个小技巧:如果两个工具功能相近,在描述里明确写"当 X 情况时用本工具,不要用 Y 工具",用否定式帮模型区分。实测下来,加了这种区分后,误调率明显下降。
5.2 工具执行抛异常打断整个循环
工具执行函数里抛异常,如果没被框架捕获,会直接中断代理循环,用户看到的就是一个报错。更糟的是,有些异常是"业务上正常"的,比如查不到数据、参数不合法,这些不该当成系统错误。
我的做法是:工具内部对可预期的失败返回结构化结果(比如{ error: 'not_found', message: '...' }),让模型自己决定怎么跟用户解释;只有真正的系统异常(数据库连不上、网络超时)才抛出去,并且在外层做统一捕获和重试。这样代理的健壮性会好很多。
5.3 状态并发写入导致历史错乱
多回合代理在并发场景下有个隐蔽的坑:同一个会话如果同时来了两个请求,两个请求都读到旧历史、各自追加消息、再写回去,后写的会覆盖先写的,导致丢消息。用户快速连发两条消息时就可能触发。
解决办法有两种:一是用 Firestore 的事务(transaction)做读改写,保证原子性;二是给会话加一个"处理中"的锁标记,同一会话串行处理。事务更通用,但要注意事务里不能做太重的操作。我一般用事务,配合乐观锁(版本号)来检测冲突,冲突了就重试。
5.4 模型返回的工具调用格式不合法
偶尔模型会返回格式不对的工具调用,比如参数缺字段、类型不对、或者调了一个不存在的工具。这在工具多、schema 复杂的时候更容易出现。Genkit 的 schema 校验能挡掉一部分,但挡不住"调了不存在的工具"这种。
我的处理是:在循环里对每次工具调用做校验,校验不过就把错误信息作为工具结果喂回模型,让它重新决策。这相当于给模型一次自我纠正的机会。实测下来,大部分格式问题模型能在下一轮自己修好。如果连续几次都修不好,就中断并返回兜底回复。
6. 让代理真正好用的几个进阶思路
6.1 给代理加"记忆"而不只是"历史"
历史是"这次对话说了什么",记忆是"这个用户是谁、有什么偏好"。两者不是一回事。一个真正好用的代理,应该能跨会话记住用户的基本信息和偏好,比如"这个用户偏好简洁回复""这个用户是 VIP,优先处理"。
实现上,可以在 Firestore 里单独存一份用户档案,代理在处理请求时先读用户档案,把关键信息注入到系统提示里。这样即使用户开了一个全新会话,代理也能"认识"他。这个能力对客服、助手类应用价值很大,做起来也不复杂,就是多一次读取和一次提示拼接。
6.2 工具结果的二次加工
工具返回的原始数据往往不适合直接给模型看。比如数据库返回一个包含几十个字段的对象,全塞给模型既浪费 token 又干扰判断。我习惯在工具执行函数里做一层加工,只返回模型真正需要的字段,并且用自然语言组织一下。
举个例子,查订单返回的原始数据有十几个字段,但模型只需要状态、物流、预计送达。工具就直接返回这三个,甚至可以拼成一句话"订单已发货,最新物流是 XX,预计 X 月 X 日送达"。这样模型拿到就能直接用,不用再解析。加工这层做得好,代理的回答质量会明显提升。
6.3 用开发者 UI 做代理调试
Genkit 带的开发者 UI 是调代理的利器。它能展示每次模型调用的完整输入输出、工具调用的参数和结果、整个循环的步骤。代理行为不符合预期时,别靠猜,打开 UI 看每一步实际发生了什么,问题往往一目了然。
我调代理的固定流程是:先在 UI 里跑一遍典型场景,看循环走了几步、每步模型说了什么、工具返回了什么;定位到问题环节后再改代码;改完再跑一遍对比。这个流程比盲改代码高效太多。生产环境记得关掉 UI,它只适合开发期。
6.4 成本控制:别让代理悄悄烧钱
代理比单次调用贵,因为一次用户请求可能触发多次模型调用和多次工具调用。成本控制要从几个地方下手:模型选型别一味求大、历史裁剪控制 token、轮次上限防止死循环、工具结果精简减少输入。我还会加一个监控,记录每个会话的模型调用次数和 token 消耗,异常高的会话单独看,往往能发现优化点。
有个容易被忽略的点:工具调用本身也可能有成本(比如调第三方 API 按次收费)。所以工具设计上要避免"模型反复调同一个工具拿同样的结果",可以在工具层加缓存,相同输入短时间内直接返回缓存结果。
7. 从能跑到好用,中间差的是什么
把代理跑起来不难,难的是让它稳定、可控、可维护。我做完几个代理项目后最大的体会是:代理的质量上限由工具设计决定,稳定性下限由状态管理和错误处理决定。模型再强,工具描述写得烂、状态存得乱、异常没处理,代理照样不可用。
另一个体会是别过度设计。一开始就想做"全能代理",挂几十个工具,结果调试地狱。正确的做法是从一个明确场景、两三个工具开始,跑通、跑稳,再逐步加能力。每加一个工具,都要重新测一遍典型场景,确保没破坏原有行为。
最后说个实操建议:给代理写测试。不是那种端到端跑模型的测试(太慢太贵),而是针对工具函数、状态读写、消息裁剪这些纯逻辑部分的单元测试。这些部分才是 bug 高发区,测好了,代理的稳定性就有底了。模型行为那部分,用固定的输入输出做回归对比,改动后跑一遍看有没有退化。这套组合下来,代理的迭代会踏实很多。