Genkit的代理API我用了大半年最深的感受是它确实把“多回合AI代理”的门槛从框架级降到了配置级。以前要自己写上下文管理、工具调用循环、会话隔离现在几个API就能串起来。这篇文章我会从零开始用Genkit的代理API做一个能记住上下文、多轮调用工具完成复杂任务的AI代理并把我在实际项目中踩过的坑和排查方法一并分享出来适合刚接触Genkit的开发者也适合想从“单轮Prompt调用”升级到“真正能干活的多回合Agent”的朋友。1. 先想清楚多回合AI代理到底难在哪1.1 模型本身不记得你上句话说了什么很多人第一次做AI应用时都会困惑为什么我调API时把上一轮对话拼进Prompt里模型还是经常答非所问原因很简单大模型API天然是无状态的它只会根据你这次请求里给的上下文去生成内容。所谓“多回合对话”本质上是我们开发者自己硬生生把一个连续会话拆成了多次独立的请求再手动把聊天记录塞回去。这个“手动塞回去”的动作第一次写可能觉得还好不就是把历史消息拼成数组传过去吗但真正做起来就会发现要处理的细节远比想象中多历史消息按什么格式传、太长了要不要截断、用户改了需求后老话还要不要保留、工具调用产生的那一大堆中间结果算不算对话内容……这些都是多回合AI代理的核心问题也正是Genkit代理API着力解决的部分。1.2 从“单轮调用”到“多回合代理”中间隔了四层能力我习惯把多回合代理拆成四层能力方便评估一个框架到底帮你做了什么能力层要解决的问题没有框架时你需要自己写的代码对话记忆记住用户之前说了什么自己拼接消息序列、管理截断策略工具编排模型决定调用工具并返回结果自己解析函数调用请求、执行、回填上下文状态持久化多个会话互相隔离、可恢复自己设计会话ID、写存储、处理并发容错机制工具报错、模型循环调用、超时自己写循环上限、异常分支、日志追踪当你只是做一个“帮我写个文案”的单轮工具时这些层都不存在但当你想做一个“能连续追问用户偏好、查天气、算预算、最后给出旅行方案”的Agent时每一层都会变成真实的代码量。我第一次手动实现这个循环时光工具调用的正则解析就写了半天所以后来切到Genkit的代理API后才意识到框架化解决这些体力活的价值。1.3 为什么最终选了Genkit的代理API在选择技术方案时我当时主要对比了几个主流方向用LangChain或LlamaIndex、纯手工编排、直接上Genkit。LangChain生态大但抽象层级多出问题时不容易一眼看穿纯手工编排最灵活但多回合会话和工具循环的代码要自己维护迭代速度慢。Genkit让我觉得顺手的原因有三个。第一它把“Agent”视作一等公民不像其他框架要层层组合Chain、Memory、Tool这些概念而是直接声明一个Agent把模型、工具、提示词放进去就行。第二它的运行时内部已经内置了Agent Loop的调度模型要调用工具时会自动循环执行不需要我写while循环。第三它自带一个开发调试UI可以看到每一轮消息完整送进模型了什么、工具返回了什么这对排查多回合问题来说简直是雪中送炭。2. 项目骨架从零开始初始化Genkit环境2.1 安装CLI并初始化项目我先说操作步骤。Genkit目前对TypeScript的支持最成熟我的项目也都是用TS写的。新建一个目录后先初始化npm项目再安装Genkit CLI和相关依赖。mkdir trip-agent-demo cd trip-agent-demo npm init -y npm install genkit-ai/core genkit-ai/googleai genkit zod这里解释一下每个包的角色genkit-ai/core是核心运行时genkit-ai/googleai是Google生成式AI模型的插件用来接入Gemini系列genkit提供CLI和开发者工具zod则是用来声明工具输入输出Schema的Genkit内部会用它做参数校验和格式约束。初始化完依赖后推荐执行一下npx genkit init这个命令会帮你生成基础目录结构和配置文件如果你完全不想用它的模板也可以像我一样手动建源码目录。我习惯的结构是src/ agents/ # Agent定义 tools/ # 自定义工具 config/ # 模型和插件配置 prompts/ # 提示词文件 output/ # 调试日志输出2.2 配置模型提供方云端和本地模型怎么切换Genkit的一大特点是可以配置多个模型提供方开发时和上线时用不同模型很常见。我的配置通常长这样import { genkit } from genkit-ai/core; import { googleAI, gemini } from genkit-ai/googleai; import ollama from genkitx-ollama; export const ai genkit({ model: gemini(gemini-2.0-flash), plugins: [ googleAI({ apiKey: process.env.GEMINI_API_KEY }), ollama({ servers: [{ url: http://localhost:11434 }] }), ], });注意genkitx-ollama这个包名它是Genkit的Ollama插件让你能在本地跑Llama、Qwen这类开源模型。这个能力在开发调试阶段特别有用因为本地模型没有成本可以在初期疯狂测试Agent行为等确定逻辑没问题了再切到云端模型跑正式场景。我建议所有刚开始接触Genkit的人都先装一个Ollama把模型配成ollama/llama3.1之类的本地模型而不是直接用云端API这样测试循环会快很多也省掉不必要的费用。2.3 Dev UI多回合调试的利器Genkit装好后运行npx genkit start会启动一个本地开发服务器默认地址是http://localhost:4000。这个页面我愿称之为“多回合Agent事故现场还原器”。它能看到什么最核心的是每次请求的完整轨迹包括用户输入到底发给了模型什么内容Agent决定调用了哪个工具工具返回了什么原始结果模型拿到工具结果后又生成了什么有一次我的Agent在第三轮对话时突然开始胡说八道我翻代码半天没找到原因结果打开Dev UI一看发现是历史消息里混入了一条工具返回的原始JSON模型被那一段JSON带偏了。这种问题不通过完整的追踪视图排查光靠猜prompt配置不知道要猜到什么时候。3. 核心实现用代理API搭建旅游规划助手3.1 先定义三个工具我这次要做的示例是一个旅游规划助手名头听起来复杂但实际上只需要三个工具查天气、算预算、推荐景点。工具的意义在于让Agent拥有超越“文本生成”的能力——它能当作“手脚”去获取真实信息。先定义天气工具import { z } from zod; export const getWeather ai.defineTool( { name: getWeather, description: 查询指定城市未来3天的天气情况用于行程规划时判断是否需要带雨具、调整户外安排, inputSchema: z.object({ city: z.string().describe(城市名称如大理、丽江), }), outputSchema: z.object({ city: z.string(), forecast: z.array( z.object({ date: z.string(), condition: z.string().describe(天气状况如晴、多云、小雨), highTemp: z.number(), lowTemp: z.number(), }) ), }), }, async ({ city }) { // 实际项目中这里替换为真实天气API return { city, forecast: [ { date: 2025-02-01, condition: 晴, highTemp: 18, lowTemp: 6 }, { date: 2025-02-02, condition: 多云, highTemp: 16, lowTemp: 7 }, { date: 2025-02-03, condition: 小雨, highTemp: 14, lowTemp: 5 }, ], }; } );再定义预算工具export const calculateBudget ai.defineTool( { name: calculateBudget, description: 根据目的地、天数和人均预算计算一个旅行团的总预算范围返回交通、住宿、餐饮、门票各项估算, inputSchema: z.object({ destination: z.string(), days: z.number(), travelers: z.number(), perPersonBudget: z.number().describe(每人总预算单位元), }), outputSchema: z.object({ totalRange: z.object({ min: z.number(), max: z.number() }), breakdown: z.record(z.string(), z.string()), }), }, async ({ destination, days, travelers, perPersonBudget }) { // 简化逻辑预算拆分的小逻辑真实场景可能查询价格数据库 const perDay perPersonBudget / days; const min perPersonBudget * travelers * 0.9; const max perPersonBudget * travelers * 1.15; return { totalRange: { min: Math.round(min), max: Math.round(max) }, breakdown: { 交通: 往返交通约${Math.round(perDay * 0.3)}元/人/天, 住宿: 住宿约${Math.round(perDay * 0.35)}元/人/天, 餐饮: 餐饮约${Math.round(perDay * 0.2)}元/人/天, 门票: 门票及其他约${Math.round(perDay * 0.15)}元/人/天, }, }; } );再补充一个景点推荐工具export const getAttractions ai.defineTool( { name: getAttractions, description: 获取指定城市的Top 5景点列表每个景点附带建议游玩时长和门票参考价, inputSchema: z.object({ city: z.string(), }), outputSchema: z.object({ attractions: z.array( z.object({ name: z.string(), duration: z.string(), ticket: z.string(), }) ), }), }, async ({ city }) { // 实际项目中替换为景点数据库或API return { attractions: [ { name: 古城漫步, duration: 3小时, ticket: 免费 }, { name: 苍山索道, duration: 4小时, ticket: 120元 }, { name: 洱海骑行, duration: 半天, ticket: 80元租车 }, ], }; } );我特意在每个inputSchema里加了.describe()这个细节很重要。模型决定是否调用工具、传入什么参数时依赖的就是这段描述。你描述越具体模型传错参数的概率就越低。比如description里写了“用于行程规划时判断是否需要带雨具”模型看到一个用户说“我担心下雨”就知道可以调用这个工具来查天气。3.2 用defineAgent定义多回合代理工具准备好了现在用Genkit的代理API把Agent定义出来。这一步是整个项目的核心export const tripPlannerAgent ai.defineAgent( { name: tripPlanner, description: 旅游规划助手负责收集用户旅行需求并调用工具查询真实信息, model: gemini(gemini-2.0-flash), tools: [getWeather, calculateBudget, getAttractions], prompt: 你是一位经验丰富的旅游规划助手。你会通过多轮对话逐步了解用户的旅行需求。 在对话过程中你必须遵守以下规则 1. 当用户提到想去某个城市但你没有天气信息时调用 getWeather 查询。 2. 当用户提到预算、人数、天数等信息时调用 calculateBudget 计算大致花费。 3. 当用户需要景点推荐时调用 getAttractions 获取真实景点列表。 4. 如果用户提供的信息不足比如只说了目的地没说天数先用自然语言追问不要急着调用不完整的工具。 5. 最终回答要给出一个整合的、可执行的行程方案并注明哪些数据来自工具查询。 , }, async (input, { messages, session, generate }) { // 这里可以在Agent自动处理之余插入自定义逻辑 const userMessage input; return { message: userMessage }; } );这里可能有人会问为什么defineAgent的handler里好像没写什么东西Agent不还是能处理多回合对话吗这就是Genkit代理API设计的巧妙之处——它把“多回合记忆累积”和“工具调用循环”都放在了框架内部在handler被调用前Agent已经自动把历史消息接上了。handler只负责把用户的最新输入变成消息内容然后Agent运行时就会带着完整上下文继续生成。如果你有自定义逻辑需求比如要在某一类输入时强行修改用户消息或者要追加一条系统提示可以在handler里操作session或messages后面我会专门讲会话管理。3.3 系统提示词告诉Agent什么时候该问、什么时候该做很多人在Agent上效果不好90%的锅并不在模型而在系统提示词写得太含糊。你要明白模型本身并不知道你的产品规则是什么它只有通过提示词才能理解“何时该调用工具、何时该先反问用户”。我总结了一个提示词三段式写法你在自己的Agent里也可以直接套用第一段说明身份和任务边界让模型知道自己是“旅游规划助手”不是“诗词生成器”。第二段用“当……时调用……”的句式明确绑定触发条件这是最有效的手段。第三段写清楚“禁止”或“避免”的行为。比如“信息不足时不要直接瞎猜”这种负面约束往往比正面命令更关键。再补充一个细节提示词里出现工具名时要和defineTool里的name保持一致。如果你在提示词里写了getWeatherInfo但工具定义叫getWeather模型可能就会产生一个错误调用的幻觉Genkit会直接报校验失败。3.4 完整对话流程拆解它到底是怎么“多回合”的我把一次真实的对话过程走一遍你能清楚地看到多回合代理和普通问答的差异。用户第一句话说“我想带家人去云南玩五天预算大概两万左右。”Agent收到后内部流程是这样的先检查自己手上的已知信息——目的地“云南”太宽泛了五天、两万、带家人这些信息已经有了但“家人”有几个人云南那么大具体去哪个城市天气如何预算算出来是否合理这些都没有。于是模型根据系统提示词里的第4条规则选择不调用工具而是生成反问“你们一共几位更偏向大理、丽江还是西双版纳那种风格”用户第二句话回答“四大一小想去大理和丽江主要想看风景。”这时Agent得到的完整上下文是“第一轮对话内容 第二轮新输入”。模型判断目的地比较明确了可以去查天气人数5人、天数5天、人均预算4000元也能算预算了。于是它发起工具调用先调getWeather查大理和丽江的天气再调calculateBudget算5人5天预算还调用了getAttractions获取景点列表。Genkit的运行时会把这三个工具请求依次执行把结果作为“工具消息”送回给模型。模型看过天气、预算、景点数据后最终组织出一份完整行程建议并明确引用“根据计算预算范围大约在……”这类表述。你发现没有从用户角度看这只是多说了几句话但系统内部已经完成了“理解需求 - 反问补全 - 并行调用多个工具 - 汇总推理”的完整链路。如果没有代理API这一大段逻辑全得自己手写。4. 会话记忆与状态管理让Agent记住上一轮的事4.1 Genkit的会话模型是怎么工作的多回合代理最核心的性质就是跨轮次记忆。Genkit里有一个边界清晰的抽象一个session代表一段完整的对话每个会话都有唯一的sessionId。在同一个sessionId下所有消息会被自动累积并跟随请求发送给模型。你可以把session想象成一个“聊天窗口”只是这个窗口不是在手机上滑动而是存在服务端。当用户刷新页面、断网重连甚至换了一台设备只要请求带上同一个sessionIdAgent就能接着之前的对话继续。在我自己的实现里通常是这样创建会话的const sessionId req.body.sessionId ?? trip-${Date.now()}; const response await tripPlannerAgent.run({ input: { message: userMessage }, sessionId, });这里有个我早期踩过的坑如果你每次请求都不传sessionIdAgent会默认创建一个新会话等于用户每句话都被当成一段全新对话上下文当然就“失忆”了。很多新手说多回合Agent不生效八成就是漏了这个参数。4.2 会话里不只是消息还可以存自定义状态除了聊天记录session还能保存开发者自定义的状态数据。这个功能特别适合用来存Agent在对话中“积累出来的结论”。举个例子用户一开始可能说自己喜欢安静、不喜欢商业化的景点这个偏好你可以塞进session.state之后每一轮Agent都能读取比每次都让模型从历史消息里自己找更高效、更稳定。// 在Agent handler里读写session状态 async (input, { session, messages }) { const prefs session.state.preferences ?? {}; if (input.message.includes(安静)) { session.state.preferences { ...prefs, vibe: quiet }; } // 后续逻辑可以基于session.state做判断 return { message: input.message }; }我在实践中的体会是session.state适合放“结构化、需要稳定使用的信息”比如用户ID、偏好标签、上一步操作的意图而“自然语言的完整聊天过程”就交给消息历史去管。两者配合Agent才能既保持灵活性又足够稳定。4.3 把聊天记录存到外部存储才能真正上线默认情况下Genkit的会话数据存在内存里。内存存储对开发调试没问题但一旦服务重启或有多实例部署就会丢上下文。生产环境我建议至少要做这两个改造之一一是Redis存储。Genkit允许你自定义会话存储实现Redis是最常见的方案因为它的SETEX天然适合给会话设置过期时间。每次Agent运行时先按sessionId从Redis拉历史消息跑完后把新消息写回去。这样即使用户隔两天再回来也能接着聊。二是数据库落库。如果项目有现成的Postgres或MySQL我倾向于把完整消息序列和session.state落库。缺点是每次请求都要读库延迟会比Redis高一些优点是方便后续做用户行为分析、审计回溯。如果你不想自己维护存储Genkit官方还提供了一些开箱即用的方案具体可以根据你用的后端存储选插件。但我要提醒一点无论选哪种外部存储一定要把session.state和消息历史看成一个整体去持久化缺少任何一部分都会导致会话恢复后行为异常。5. 工具循环与编排细节Agent Loop的台前幕后5.1 Agent Loop的完整生命周期为什么模型能自动决定调用什么工具这背后的机制叫Agent Loop也叫工具调用循环。我把它拆成七个步骤你看一遍就明白系统提示词和历史消息聚合作为输入送进模型。模型生成回复。如果它觉得不需要调用工具生成的就是最终文本循环结束。如果模型觉得需要外部信息它不会直接写文本而是生成一个结构化的“工具调用请求”里面包含工具名和参数。Genkit解析这个请求校验工具名是否存在、参数是否符合Schema然后把请求派发给对应的工具函数。工具函数执行返回结构化结果。这个结果被包装成一条“工具消息”追加到会话上下文中再次送回给模型。回到第2步模型继续生成——它可能输出最终答案也可能继续调用下一个工具。这个循环对开发者是透明的所以你写defineAgent时完全不用手写循环。我第一次跑通这个流程时嘴里冒出的第一句话就是“原来如此”。5.2 怎样防止Agent陷入无限循环Agent Loop虽然方便但有一个所有框架都逃不掉的问题模型在Tool Call循环里出不来。比如它反复调用同一个工具参数一样结果一样却始终不生成最终回答看起来就像程序死循环了。Genkit提供了配置参数来限制循环次数我在项目中通常会显式设置const ai genkit({ model: gemini(gemini-2.0-flash), }); // 在定义Agent时可以配置 export const tripPlannerAgent ai.defineAgent({ name: tripPlanner, tools: [getWeather, calculateBudget, getAttractions], model: gemini(gemini-2.0-flash), maxIterations: 8, // 限制工具调用循环最多8次 timeout: 120000, // 单次运行整体超时单位毫秒 });maxIterations设得太大浪费token和时间设得太小又可能导致Agent还没查完数据就被打断。我做过的项目里纯工具类Agent设8到10次比较稳涉及多步骤任务可以稍微放宽到15次。另外超时配置也要按场景调如果工具本身要调外部HTTP接口单次超时建议至少30秒整体超时就按“工具次数 x 平均工具耗时”来估。5.3 多工具协作时的两个坑第一个坑是参数串味儿。模型在多个工具间做调度时如果工具Schema中的字段名有重合比如getWeather用了citygetAttractions也用了city模型倒不至于搞混但如果你把第二个工具的参数命名为place、location、destination三套叫法模型就会开始犯错。我的经验是所有工具的入参字段尽量用同一套命名体系比如全用destination这样模型泛化更强。第二个坑是结果过大。工具返回的数据如果冗长会让整个上下文迅速膨胀。比如真实天气API可能返回每小时一条数据连续15天的量很大。我建议工具函数在返回前就做好精简只返回Agent真正需要生成结论的字段。必要时可以在工具内部把原始数据聚合好再返回一个精简摘要。6. 真刀真枪的坑常见问题与排查实录6.1 对话到第三轮就“失忆”了这是一个出现频率极高的问题现象是前两轮聊得好好的第三轮问Agent“我刚才说的那个城市你记得吗”它一脸茫然。排查思路按下面的顺序来确认每次请求是否传了同一个sessionId。这是最常被忽略的。用日志把每轮请求的sessionId打出来一眼就能看清楚。如果在本地用Dev UI测试Dev UI默认会帮你管理会话但你自己通过API调用时就要显式传sessionId。确认会话存储没有被清空。如果跑在无状态服务器上重启后内存存储就没了现象就是“隔一会回来就失忆”。解决方法是接外部存储参考4.3节。检查消息历史长度。上下文超过模型窗口限制时Genkit会触发截断策略如果配置不当截断后的上下文恰好丢掉了早期关键信息。6.2 工具参数“它老编错”现象是工具调用时传入了明显不对的参数比如用户问“大理天气”Agent却调了getWeather并传入{ city: 云南 }或者把字符串传给了数字字段。这类问题的根因基本有三个一是工具描述和Schema描述太模糊。我前面提到过inputSchema里的每个字段都要写清楚.describe()并且description里要写清楚工具的作用边界。这一步做扎实90%的参数错误都能解决。二是提示词里出现冲突信息。比如你在工具定义里说参数city必须传具体城市但在自定义提示词里又写了“云南地区可以统一查一个代表城市”这就会诱导模型乱来。提示词和工具描述要互不冲突。三是某些模型底座的工具调用能力本来就弱。如果你用本地小模型遇到工具参数总是错先别怀疑代码试着把温度调到0或0.1再看看是不是模型本身的问题必要时切回更强的模型。6.3 多会话串号生产环境的会话隔离多回合代理上线后最容易出的“事故”就是A用户问的问题B用户看到了自己的回答里混进了A的内容。这个问题的本质是sessionId在请求之间没有做严格隔离。我的经验是在服务入口统一处理从请求头或授权信息中解析用户身份再生成或者映射到对应的sessionId而不是直接信任前端传过来一个自定义的sessionId。前端传来的sessionId只能作为辅助标识不能作为唯一的会话凭据。否则只要有人随手把自己的sessionId改成别人的就能看到别人对话的完整上下文。6.4 调试三件套日志、Dev UI、模拟数据最后分享我排查Genkit多回合Agent问题的“三件套”套路适配率高到惊人。第一步先在代码里打开详细日志。Genkit支持logLevel: debug开启后会在终端打印每个请求的详细内部信息包括消息数、上下文内容、工具调用过程。看到这个日志你基本就知道Agent“脑子里”在想什么了。第二步如果日志信息还不够直观打开Dev UI按照时间线模式查看完整交互过程。点击某一条消息能看到模型收到的实际Prompt长什么样、工具返回的原始JSON是什么。排查上下文污染、提示词冲突这类问题时这个可视化视图几乎是唯一高效的手段。第三步用一套固定的模拟数据做回归测试。我建议为Agent准备几个标准测试案例比如“用户只说目的地看Agent会不会追问”、“用户直接给全部信息看Agent会不会直接出方案”、“用户中途改预算看Agent会不会更新计算”。每次改动提示词或工具后都跑一遍这些案例比临时手动随机对话高效得多。最后再分享一个我个人的使用习惯构建多回合Agent不要一上来就追求复杂工具集先让Agent在只有一两个工具的约束下把“多轮对话 上下文累积 简单工具调用”跑通再逐步加工具。每加一个工具都要测试它在“信息不足”“参数错误”“结果异常”三种情况下的表现是否可控。这样迭代下来你的Agent稳定性会比一次性写完十来个工具再调试高很多。Genkit的代理API最大的价值不是减少你写代码的行数而是让你可以把精力放在真正影响用户体验的地方——提示词的质量、工具返回的准确性、会话状态的灵活性。这些才是多回合AI代理产品力的核心。