我最近在折腾一个挺有意思的东西:用 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/openai;@genkit-ai/ollama是为了后面接本地模型做准备;tsx用来直接跑 TypeScript 文件。
然后在package.json里加一个脚本:
"scripts": { "dev": "genkit start -- tsx src/main.ts" }genkit start会在 4000 端口启动开发者面板,同时启动你的业务代码,后面调试就靠它。
2.2 配置一个可用的模型
写一个最小的入口文件src/main.ts:
import { 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): Promise<any[]> { 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 代理助手才算真正能打。