Genkit代理API实战:多回合对话AI代理的工程化构建
发布时间:2026/9/30 5:58:09来源:尧图网络
我最近在折腾一个挺有意思的东西用 Genkit 的代理 API 搭了一个支持多回合对话的 AI 代理。大家都知道所谓“多回合”最难的不是让模型回答一句话而是让代理在整个会话里记住前面聊了什么、干了什么并且能自己决定在哪个步骤调用哪个工具。以前我都是手写一个 while 循环把历史消息一遍遍拼进去后来发现状态管理、工具结果回填、上下文膨胀这些问题会把人折磨疯。Genkit 给我的感觉是它把“代理”这个概念真正做成了工程上的可操作 API而不是拿提示词硬凑。这篇文章适合三种人一是已经用 LangChain、Semantic Kernel 或其他框架写过 Agent但被会话状态和工具循环坑过的人二是刚接触 Genkit想搞清楚它的 Agent API 和 flow 是什么关系的人三是手里有本地模型、想让 AI 代理助手脱离云端 API 也能跑起来的人。我会把整条链路拆开来讲从环境搭建、代理定义、工具封装到多回合会话存储再到把代理暴露成 HTTP 接口最后接入 Ollama 本地模型全部用可运行的代码示例说话。1. 为什么我放弃了手写 Agent 循环改用 Genkit 的会话编排先说说我过去是怎么写多回合代理的。基本上就是下面这个套路把用户消息、助手回复、工具调用结果统统塞进一个 messages 数组然后每次都把这个数组完整地发给模型。模型返回一个 tool call我就手动执行函数把结果拼成一个 tool 消息再丢回去直到模型不再调用工具为止。这套逻辑本身没问题麻烦的是它会快速变形。1.1 手写循环的三个坑状态丢失、上下文膨胀、调试靠日志第一个坑是状态丢失。本地定义的变量进程一重启就没了就算不重启某个分支忘记把工具结果写回 messages模型下一次就拿不到关键信息。第二个坑是上下文膨胀。会话超过十几轮之后消息数组越来越大模型要处理的 token 越来越多响应开始变慢甚至开始遗漏早期的指令。第三个坑是调试。手写循环的时候出了问题我只能靠 console.log 打印整个 messages肉眼在一堆 JSON 里找到底是哪一轮出了问题非常痛苦。后来我接触到了 Genkit它把“对话状态”当成了框架里的第一等公民而不是我自己的临时变量。它的 Agent 相关 API 会替你维护消息历史、工具调用的回填、多轮的终止条件。最直观的变化是我在代码里不再写 while 循环只需要描述“代理有什么工具、由哪个模型驱动、最多跑几轮”剩下的执行细节交给运行时去处理。这就是我理解里的“代理 API”——不是让你去调用某个远程的 agent 服务而是给你一组定义代理的 API帮你完成代理循环的编排。1.2 Genkit 这套方案的核心优势在哪Genkit 本身不是一个 Agent 框架它更底层是一个 AI 应用的编排运行时。你可以把它理解成一个后端服务框架加上一个模型调用网关。它先解决了“模型供应商碎片化”的问题OpenAI、Anthropic、Google Gemini、Ollama 本地模型都是用同一个generate接口去调用换模型只是换一个字符串配置。在这个基础上Genkit 从 1.x 版本开始加入了完整的 Agent 定义能力比如defineAgent、defineTool还有专门用于多回合会话的历史管理机制。我最看重的还不是多模型而是可观测性。Genkit 自带 Dev UI代理每一次思考、每一个工具调用、每一轮消息返回都会以 trace 的形式记录下来。这个对开发体验的提升是非常直接的。调试多回合代理不再靠猜你能打开面板看到模型在每一轮到底看到了哪些消息、调用了哪个工具、工具返回了什么。后面我会专门用一节来讲怎么用好这个调试面板。先把环境跑起来让你能亲手感受到这套东西的爽点。2. 搭好环境先跑通一个能记住上下文的代理工欲善其事必先利其器。Genkit 当前对 TypeScript 和 Go 的支持最成熟我主要用 TypeScript。下面所有代码基于 Genkit 1.x建议你用最新版API 以你安装时的版本为准。2.1 初始化工程与依赖选择创建一个项目目录然后初始化 npm 包mkdir genkit-agent-demo cd genkit-agent-demo npm init -y npm install genkit genkit-ai/googleai genkit-ai/ollama tsx我装了几样东西核心的genkit包提供 flow、agent、tool、generate 这些基础能力genkit-ai/googleai是 Google Gemini 的插件如果你打算用 OpenAI就换成genkit-ai/openaigenkit-ai/ollama是为了后面接本地模型做准备tsx用来直接跑 TypeScript 文件。然后在package.json里加一个脚本scripts: { dev: genkit start -- tsx src/main.ts }genkit start会在 4000 端口启动开发者面板同时启动你的业务代码后面调试就靠它。2.2 配置一个可用的模型写一个最小的入口文件src/main.tsimport { genkit } from genkit; import { googleAI, gemini15Flash } from genkit-ai/googleai; const ai genkit({ plugins: [googleAI()], model: gemini15Flash, }); export default ai;启动之前到 Google AI Studio 申请一个 API Key然后设置环境变量export GOOGLE_GENAI_API_KEY你的key如果不方便用云端 API你可以跳过这步直接看第六节用 Ollama 本地模型。不过第一次演示多回合能力用 Gemini 会更省心本地模型在工具调用的稳定性上稍弱一点。2.3 一个最简代理两句对话验证多回合记忆Genkit 定义代理最直接的方式是defineAgent。看下面的例子import { genkit, z } from genkit; import { googleAI, gemini15Flash } from genkit-ai/googleai; const ai genkit({ plugins: [googleAI()], model: gemini15Flash, }); const agent ai.defineAgent( { name: conversationalAgent, description: 一个能记住上下文的多回合助手, model: gemini15Flash, systemPrompt: 你是一个乐于助人的助手。回答要简洁、准确。, } ); // 模拟两轮对话 const history: any[] []; const firstUserMessage { role: user as const, content: [{ text: 你好我叫小明我在开发一个AI代理。 }], }; const firstResponse await agent.run({ messages: [...history, firstUserMessage], }); history.push(firstUserMessage, ...firstResponse.messages); const secondUserMessage { role: user as const, content: [{ text: 我刚才说我叫什么名字我在做什么 }], }; const secondResponse await agent.run({ messages: [...history, secondUserMessage], }); console.log(secondResponse.text());这里最关键的一行是history.push(firstUserMessage, ...firstResponse.messages)。agent.run返回的结果里带着完整的消息历史不只是最新的文本答案。你必须把上一轮的结果原样塞回下一轮的输入模型才能记得“小明”和“AI代理”这件事。如果你运行上面的代码第二轮模型的回答就会明确提到小明的名字和他在开发 AI 代理。这就完成了一个最小可用的多回合代理。可能你会觉得这也没多神奇无非是把历史消息传回去。别急真正的快感在于给这个代理加上工具让它能够在多回合对话里自己去调用外部接口。3. 代理 API 如何运作从一次模型调用到循环执行工具多回合对话真正复杂的地方是代理不仅仅要“聊”还要“做”。用户说“帮我查一下订单现在到哪儿了”代理需要决定调用订单查询接口拿到接口返回的数据之后如果用户又说“那这个包裹能改送到公司吗”代理需要再调用地址修改接口。整个过程中模型在背后经历了多次内部推理而对用户来说就是一次自然的对话。这正是 Genkit 的工具循环帮你完成的事。3.1 一个会话内的“请求—工具—再请求”闭环当你给代理注册了工具每次agent.run被调用时代理内部会执行一个大致的循环把当前消息历史和工具定义一起发送给模型。模型判断是否需要调用工具。如果不需要直接返回自然语言答案循环结束。如果需要模型会返回一个结构化的toolCall包括工具名和参数。运行时在你本地执行对应的函数把结果包装成一个tool角色的消息。把这条工具结果消息追加到历史里再次发送给模型。回到第 2 步直到模型不再请求工具或者达到最大轮数上限。这个循环在 Genkit 里是受控的、可观测的而且每轮生成的消息都会出现在返回的messages里。这意味着你在多回合会话中不需要自己区分“哪句话是用户说的、哪句话是工具吐出来的”只需把上一轮messages原样传给下一轮即可。3.2 用 defineTool 把 HTTP 接口封装成模型可用的代理 API所谓“代理 API”在我这个项目里还有一层意思把我自己的业务接口封装成代理可以调用的工具。比如我有一个内部订单服务接口是这样的GET /api/order/{orderId} 返回 { status: shipped, currentLocation: 上海转运中心 }我可以通过defineTool把它封装成一个模型能理解的工具import { z } from genkit; const getOrderStatus ai.defineTool( { name: getOrderStatus, description: 根据订单号查询物流状态返回当前包裹位置与状态, inputSchema: z.object({ orderId: z.string().describe(用户的订单号), }), outputSchema: z.object({ status: z.string(), currentLocation: z.string(), }), }, async ({ orderId }) { const response await fetch(https://api.example.com/api/order/${orderId}); const data await response.json(); return { status: data.status, currentLocation: data.currentLocation, }; } );inputSchema和outputSchema不是可有可无的装饰。模型决定用什么样的参数去调用工具就是靠 JSON Schema 来理解接口契约。你也千万不要在描述里写“如果你不确定请询问用户”而是要尽量写清楚这个工具是干什么的、参数从哪里来。描述越明确模型误调用的概率越低。把这个工具挂到代理上const agent ai.defineAgent({ name: orderAssistant, description: 帮助用户查询订单和物流信息, tools: [getOrderStatus], model: gemini15Flash, systemPrompt: 你是电商客服助手。用户查询订单时先用工具查询再根据结果回答。, });现在用户如果说“帮我看看订单 AB123 到哪儿了”模型就会自己生成一个getOrderStatus(订单号)的调用请求运行时执行完 fetch把结果交还给模型最后由模型整理成一句自然语言回答。3.3 限制循环次数与兜底策略工具循环虽然方便但也引入了一个新麻烦模型可能陷入无效调用循环也可能在一个问题上反复调用同一个工具。你需要给代理设置maxTurns限制单次run内工具调用的最大轮数。在我的客服例子里我一般设为 5 到 8。设置为 3 往往不够因为用户可能先问订单状态再根据结果问“为什么还没发货”这需要连续两三次工具调用。此外工具本身可能返回错误。我在封装工具的时候习惯在函数内部用 try/catch 把异常转成一个可读的结果而不是让异常直接抛出。如果工具返回了{ error: 订单不存在 }模型自然会告诉用户“查不到这个订单”而不是整个流程崩溃。这是我踩过坑之后学到的代理的鲁棒性不是模型给的而是工具设计给的。工具必须永远返回一个模型能够理解的数据结构而不是一个堆栈错误。4. 多回合会话的三种实现层次内存、自建文件存储与可插拔框架存储多回合代理上线之后马上会面临一个问题两个不同用户同时聊天会话状态不能混在一起用户关掉页面再回来会话要能恢复。前面我们用的history数组是放在内存里的这在单用户 demo 中完全没问题但真实服务不可能这样写。4.1 最朴素的 messages 数组和它的边界内存数组最大的优点就是简单。同一个进程里你给每个 session 维护一个数组key 是用户会话 ID。但是它有三个明显边界进程重启所有上下文全部丢失。多实例部署时session 状态只存在于某台机器的内存里负载均衡会把请求打到不同机器上下文就断了。内存无限增长。一个聊了很久的会话可能塞满上万 token你要么做截断要么做摘要而没有框架帮你意识到这件事。针对第三个问题Genkit 的模型调用层自带上下文缓存和管理逻辑但那是针对单次generate的优化。对于多回合的历史消息自建代理时仍需要自己做裁剪策略。4.2 给代理挂一个可插拔的会话存储Genkit 在后续版本中逐步完善了会话记忆的抽象。你可以在定义代理时指定sessionMemory把会话历史交给一个自定义的状态存储去管理。它的核心思想是把“保存会话状态”从你的业务代码里抽出来由框架在agent.run的入口和出口自动读取、写回。这个接口不复杂本质上是两个异步函数一个负责根据 session ID 读取历史消息另一个负责把最新消息写回。你可以用任意方式实现后端比如写到 SQLite、Redis、MongoDB甚至存成 JSON 文件。Genkit 的官方包里提供了一个内存版的状态存储方便本地跑通但我会换成我自己的文件实现方便持久化。我做了一个很小的文件存储来演示这个思路import { mkdir, readFile, writeFile } from node:fs/promises; import path from node:path; class FileSessionStore { private dir: string; constructor(dir: string) { this.dir dir; mkdir(dir, { recursive: true }); } private key(sessionId: string) { return path.join(this.dir, ${sessionId}.json); } async save(sessionId: string, messages: any[]) { await writeFile(this.key(sessionId), JSON.stringify(messages)); } async load(sessionId: string): Promiseany[] { try { const raw await readFile(this.key(sessionId), utf-8); return JSON.parse(raw); } catch { return []; } } }然后在代理调用层里这样用const store new FileSessionStore(./sessions); async function chat(sessionId: string, userText: string) { const history await store.load(sessionId); const userMessage { role: user, content: [{ text: userText }] }; const result await agent.run({ messages: [...history, userMessage], }); await store.save(sessionId, [...history, userMessage, ...result.messages]); return result.text(); }这样实现之后即使代理服务重启用户重新发来消息我也能从文件里恢复之前的对话。文件存储适合单机、低并发、轻量演示生产环境我会换成 Redis理由很简单读写快、天然支持过期时间、多个服务实例可以共享同一个会话数据。4.3 生产环境的持久化与并发注意点如果你要直接拿这个方案上生产我建议控制好两个细节。第一文件写入要防并发。同一个 sessionId 同时来了两个请求可能发生后写覆盖先 写的情况。稳妥的做法是根据 sessionId 加锁或者把会话写入丢到一个串行的队列里。第二要设置会话过期策略。不是所有用户都会聊完就走但无限保留所有消息既不合法也不经济。Redis 的 TTL 天然适合这个场景一般我设置 7 天到 30 天。你也可以把摘要压缩交给模型来做当历史消息超过 N 条就用一次generate生成一个会话摘要替代早期消息。这个操作在 Genkit 里实现很顺手因为它本质上也只是一次模型调用读历史、写摘要、继续接着聊。5. 把代理发布成真正的 HTTP 接口供其他系统调用单机的 CLI 脚本只能自己玩代理要给别人用最直接的方式是把chat函数包成一个 HTTP API。我用 Express 来实现你完全可以用 Fastify、NestJS 或任意 HTTP 框架。5.1 用 Express 承载 Agent 调用安装依赖npm install express types/express然后在入口文件里启动服务import express from express; import { agent } from ./agent; const app express(); app.use(express.json()); const store new FileSessionStore(./sessions); app.post(/api/chat, async (req, res) { const { sessionId, message } req.body; if (!sessionId || !message) { res.status(400).json({ error: sessionId 和 message 是必填项 }); return; } try { const history await store.load(sessionId); const userMessage { role: user, content: [{ text: message }] }; const result await agent.run({ messages: [...history, userMessage], }); await store.save(sessionId, [...history, userMessage, ...result.messages]); res.json({ reply: result.text(), sessionId, }); } catch (err) { console.error(err); res.status(500).json({ error: 代理调用失败 }); } }); app.listen(3000, () { console.log(Agent API listening on http://localhost:3000); });5.2 请求参数设计sessionId、message 和工具策略接口参数我只保留了最简单的sessionId和message。但真实项目中你很可能还想要一个toolPolicy参数用来告诉代理本次请求是否允许调用工具。比如某些只做内容生成的任务就不该让代理去动订单系统。我的做法是在代理定义时把它做成两个实例一个带全部工具一个不带工具然后在路由层根据请求里的mode字段选择使用哪个。模型本身有不确定性既然你能在外围把“能不能调用工具”这个开关做硬就不要把希望寄托在系统提示词上。这是我做代理服务的一个重要原则能用代码限制的边界不要用提示词去要求。5.3 返回值约定全量结果还是流式输出上面例子里的result.text()返回的是一个完整字符串。如果对话历史较长或者模型需要多次调用工具用户可能会等上好几秒。如果你的产品是聊天框强烈建议改成流式输出。Genkit 的agent.run支持传入流式回调const result await agent.run({ messages, stream: (chunk) { res.write(chunk.text); }, });你要在 Express 里使用res.write配合text/event-stream或者text/plain来把增量吐给前端。工具调用阶段的非文本事件也可以通过回调暴露出来比如“正在查询物流状态”这样的中间提示。流式输出是聊天产品的体验分水岭代码上只差一点点但给用户的感受差别很大。6. 接入本地模型让代理助手可以离线运行很多人对代理的想象是必须调用云端大模型 API 才能跑。放到生产环境不少团队有数据隐私要求不希望把对话内容发到外部服务去。这个场景正好用得上头部热词里提到的“AI 代理助手加本地模型”。6.1 本地模型最适合哪些代理场景本地模型在工具调用能力上目前普遍要比云端旗舰模型弱一些这一点我不打算粉饰。但它有几个无比诱人的优点对话数据不出内网合规压力小。一次部署长期运行没有按 token 计费的问题。适合垂直场景、固定工具集、不需要太多自由发挥的任务。比如一个内部的售后代理工具就那么三四个查询订单、查退款进度、生成工单。模型不需要会写诗只要能把“查订单”这句口语映射到getOrderStatus这个工具上调把参数提取对就算完成任务。这种场景本地 7B 到 14B 的模型完全够用。6.2 Ollama 插件配置与模型选择先把 Ollama 装好拉一个对工具调用支持较好的模型。我自己试下来Llama 3.1 8B 在单工具场景下比较稳Qwen 2.5 7B/14B 的中文指令理解不错工具调用也靠谱。拉取模型ollama pull llama3.1然后在 Genkit 里配置 Ollama 插件import { genkit, z } from genkit; import { ollama } from genkit-ai/ollama; const ai genkit({ plugins: [ ollama({ models: [{ name: llama3.1, type: chat }], servers: [{ baseURL: http://127.0.0.1:11434 }], }), ], model: ollama/llama3.1, });注意一点Ollama 里模型的type要标成chat因为代理工具调用依赖对话补全格式。你如果用 embedding 模型作为主模型跑代理会报错。6.3 本地模型在工具调用上的现实差异把之前的客服代理从 Gemini 切换到 Ollama 只需改一行模型名称但实际效果不可能完全一致。本地模型更容易犯两类错一是参数提取错误比如把订单号里相似的字符认错二是不按照工具调用格式输出而是直接用文本描述“我应该调用 getOrderStatus 工具”。第二种错误很让人头疼。针对这两类问题我的缓解措施有三招。第一工具描述里写清楚参数示例比起空泛的 schema 描述带格式示例的提示词能显著提高本地模型的抽取准确率。第二代理的systemPrompt里加一句“如果无法确认用户提供的信息向用户追问不要编造参数”。第三设置更低的温度比如temperature: 0减少自由发挥的概率。你能在 Genkit 的defineAgent配置里直接传temperature参数对不同模型分别调。我最后还要强调一个本地模型特有的坑硬件的稳定性。Ollama 允许指定并发但小显存机器吃不住高并发推理。对接生产时建议给 Ollama 设置并发上限或者在代理接口层做简单的限流。宁可让用户排队等两秒也不要让显卡爆显存导致整个服务不可用。7. 调试多回合代理我离不开的 Dev UI写代理和写普通接口最大的区别是接口的输入输出是确定的代理中间却有一堆你看不见的模型决策。这也正是 Genkit 的价值所在。它把 Dev UI 直接集成到开发流程里你在浏览器里就能看到代理每一步的内部轨迹。7.1 Trace 能看到每一步发生了什么用前面的genkit start -- tsx src/main.ts启动服务后打开http://localhost:4000。在 Dev UI 左侧的 Trace 面板里你能看到每一次agent.run的完整链路。展开一条 trace它会列出模型调用了几次每次传入的 messages 里包含哪些内容模型请求调用哪个工具传了什么参数工具实际执行后返回了什么结果最终答案是由哪一次模型调用生成的对于一个“用户问了订单代理查了接口然后回复”的请求你能一眼确认工具返回的数据没有被模型丢进垃圾堆。如果模型答错了你能在 trace 里看到它到底漏看了哪条消息。7.2 Prompt 回放解决“代理为什么突然犯傻”有些问题并不是工具执行出错而是模型理解偏了。Dev UI 里提供了 Prompt 回放功能我经常用它来做问题复盘。选中一条 trace你既可以查看格式化后的完整提示词也可以一键重新运行同样的请求。这样当用户报告“昨天还能查今天查不了了”时我先把当时的请求重放一遍再检查是不是 prompt 模板被改坏或者模型配置被切换到了更差的版本。更实用的场景是多回合记忆泄漏排查。你的代理应该只看到当前会话的历史却意外看到了别的会话内容这类 bug 用日志很难发现但用 trace 一条条对比输入消息就很容易暴露。7.3 日志与性能观察别只盯着响应时间Dev UI 还集成了评测面板你可以在上面跑一组测试样例比如“用户问订单号 123 的状态期望代理最终给出包含所在地的回答”让系统自动判断每次运行是否达标。我在上线前会把常见用户问题整理成 20 个左右样例跑一遍评测改任何提示词或模型之后都重跑。这不花什么时间但能阻止很多回归。响应时间也要分角色来看。如果用户感受到慢打开 trace 看时间消耗分布是模型生成第一句话慢还是某个工具接口响应慢还是模型在循环里调用了太多次工具。这三种慢的本质上完全不同解决手段也完全不同。最后聊点实际操作的经验就我目前的体会用 Genkit 搭多回合代理整套链路已经能支撑一个相当完整的产品原型也能扛住一定规模的生产流量。我自己现在搭建代理的默认做法是先把业务工具全部用defineTool封装好再定义代理并挂上工具然后立刻用 Dev UI 的评测面板跑一批样例。会话存储开始就上 Redis别省这一步。模型先用云端跑通逻辑稳定后再切换本地模型做验证。如果你在本地模型上遇到底层模型的工具调用格式不稳定最值得调整的就是这几处工具描述里的示例、温度参数、跟宿主组的请求退避。目前来看Llama 3.1 8B 和 Qwen 2.5 7B 在简单工具调用上是能用的但不要指望它们能像 Gemini 那样在复杂多工具场景里保持稳定。想再深入的话你可以去把 Genkit 的 flow 也结合起来用。代理适合处理自由对话flow 适合跑那些流程固定的任务比如“先查询订单再检查库存最后给出补货建议”。两个机制互相配合你的 AI 代理助手才算真正能打。
网站建设高端定制企业官网