1. 项目定位与核心思路拆解
1.1 这个项目到底在解决什么问题
先说结论:这个项目解决的是“AI代理没法记住自己说过什么、做过什么”的尴尬问题。
很多人都在玩大模型,日常的用法是“我给一句提示词,你给我一个回答”,这叫单轮对话。但真正的AI代理(AI Agent)不是这么回事。代理的核心能力是“在一个目标下连续执行多步操作”,比如让它帮我查资料、整理成表格、再写成邮件草稿,这一串动作里,每一步的结果都可能影响下一步的决策。如果模型每轮都是“失忆”状态,那这个代理根本没法工作。
所以,多回合(multi-turn)能力是AI代理从“玩具”走向“工具”的分水岭。而Genkit这个框架,恰好提供了一套比较完整的“代理API”机制,让我可以用很少的代码把“上下文管理 + 工具调用 + 多轮对话 + 本地模型接入”串起来。这篇文章就把我实际搭建这个项目的完整过程、踩过的坑、以及最终的架构方案沉淀下来。
这个项目适合谁?两类人:一类是已经玩过LangChain或者类似框架、想横向对比一下Genkit的设计思路;另一类是刚接触AI代理、想搞清楚“多回合到底是怎么实现的”的开发者。不管哪类,读完你都能获得一套可以直接跑起来的实现方案。
1.2 为什么选Genkit而不是其他框架
我最早尝试过直接用LangChain,后来也试过自己手写会话管理,但最终在“代理API + 多回合”这个场景上选了Genkit。原因有三点:
第一,Genkit是Google出品的开源框架,背后有持续维护,而且它对“流式输出、工具调用、上下文快照”这些代理核心能力做了比较深的内置支持。用官方的话说,Genkit的设计目标就是“production-ready”,几个核心抽象(Flow、Tool、Memory)之间配合得很自然。
第二,它的“代理API”概念非常贴合当前AI代理开发的真实需求——不是让你从零拼装,而是把代理循环(agent loop)封装成可以直接调用的接口。特别是“multi-turn”这个能力,Genkit天然支持在Flow中维护会话状态,每一轮对话的输入、工具调用结果、模型回复都会进入同一个上下文流,这对做真实业务非常关键。
第三,也是最实际的:Genkit对“本地模型”的接入很方便。如果你不想每次调试都花钱调云端API,可以直接把本地跑的模型接进来。这一点在我后面的调试环节里帮了大忙,这个咱们到实操部分细说。
我个人的感受是:如果你只是做“问一句答一句”的聊天机器人,用什么框架都无所谓;但当你要构建“能干活”的AI代理,框架对状态管理的设计深度,就直接决定了你的开发效率。Genkit在这个维度上做得比很多同类框架要“省心”。
1.3 整体架构先有个概念
先别急着写代码,我建议你在动手之前,把整个系统的数据流在脑子里过一遍。我这个项目的最终架构是这样的:
- 用户输入进入一个Genkit Flow,这个Flow就是整个代理的“主循环”。
- Flow内部维护一个统一的会话对象,里面包含历史消息数组、当前上下文摘要、以及可用的工具列表。
- 每一轮输入,Flow会把历史消息 + 当前输入一起发给模型,模型如果认为需要调用工具,就返回一个工具调用请求。
- Genkit的“工具执行器”会接管这个请求,执行完把结果回传给模型,模型基于结果继续生成最终回复。
- 回复生成后,连同工具调用记录一起写入会话历史,等待下一轮输入。
这个流程说起来简单,但里面有三个关键点容易翻车:一是上下文如何持久化,二是工具调用的“多轮递归”怎么控制,三是模型对“什么时候该调用工具”的判断是否准确。后面我会针对这三点逐个讲清楚。
2. 核心细节解析:代理API与多回合机制
2.1 代理API到底是“代理”了什么
我第一次接触“代理API”这个词的时候,也困惑了很久——它到底代理了什么?后来我把它理解成:代理API = 对外暴露一个统一接口,对内接管了“模型调用 + 工具选择 + 上下文维护”这三件最繁琐的事。
换句话说,你不用自己在业务代码里写“如果模型说要调用工具A,我就去执行A;如果模型说要调用工具B,我就去执行B”这种一堆if-else。代理API把这些逻辑收敛到一个统一的执行循环里,你只需要声明“我有哪几个工具,每个工具是干什么的”,然后剩下的调度交给框架。
这里有个非常重要的设计理念:工具对代理来说,是“能力”而不是“代码”。你要给模型的是“能力的描述”,而不是“能力的实现”。举个例子,我项目里有一个工具叫“查天气”,我提供给模型的描述是“根据城市名查询实时天气,返回温度、湿度、风力”,模型看到这个描述就知道“当用户问天气时,我可以调用这个工具”,但它根本不需要知道这个工具背后是调第三方API还是查本地数据库。这种解耦,才是代理能灵活处理用户各种意图的关键。
2.2 多回合的核心:别让模型“失忆”
多回合对话真正的难点,不是你写一个for循环把消息塞给模型,而是如何处理“记忆”。
我第一版实现偷懒,直接把所有历史消息一股脑全发给模型,结果很快发现两个问题:第一,上下文越积越长,API的token开销直线上升;第二,模型会被一些早期无关紧要的细节带偏,回答质量反而下降。后来我采用的方式是“分层记忆”:短期记忆保存最近N轮完整对话,长期记忆用一段摘要来概括更早的内容。
这个方案在Genkit里实现起来非常顺手。Genkit的会话对象本身就支持添加消息、检索历史,而且可以通过持久化存储(比如本地JSON文件或者数据库)把会话保存下来。我的做法是每完成一轮对话,就调用一次保存逻辑,把更新后的会话序列化成结构化数据存起来。这样即使服务重启,用户也能从上次的进度继续聊。
这里我要给一个非常具体的建议:多回合的“回合”切分,一定要以“一次工具调用 + 一次最终回复”为单位,而不是以“用户说了一句话”为单位。因为在一个回合内,可能模型已经内部调用了两三次工具,这些过程都要算进这一轮的上下文里。如果你把工具调用过程漏掉了,模型下一轮就会“失忆”,因为它不知道自己刚才已经查过天气了。
2.3 本地模型接入的隐藏价值
前面我提到,调试时用本地模型非常方便。这里展开说说。
开发AI代理最头疼的问题之一,就是“到底是我的代码逻辑错了,还是模型的理解能力不够”。如果你用的都是云端API,出错的时候你很难判断问题出在哪个环节——因为模型本身是个黑盒子。但本地模型不一样,你可以完全掌控输入输出,每一轮模型到底“看到了什么”都能清清楚楚地查出来。
当然,本地模型的劣势也很明显:推理速度慢,复杂指令的理解能力不如顶级云端模型。所以我的策略是“开发用本地模型,生产用云端模型”。Genkit的模型配置层做得很好,切换模型只需要改一行配置,不需要动业务代码。这也是我为什么愿意在项目初期花时间把代理流程搭建干净——因为流程本身是通用的,模型只是一个可插拔的“大脑”。
我现在用的本地模型方案是接一个通过Ollama跑的量化模型,具体到Genkit的配置上,就是用Genkit的模型插件包把Ollama挂进来,然后在定义Flow的时候指定model字段。平时调试就跑本地模型,ci/cd集成测试也用它,成本几乎为零。
3. 实操过程与核心环节实现
3.1 环境准备与初始化
我假设你已经有Node.js环境,版本至少是18以上。Genkit目前对Node.js的支持最成熟,我用的是20 LTS。初始化一个项目,执行:
mkdir multi-turn-agent cd multi-turn-agent npm init -y npm install @genkit-ai/core @genkit-ai/ai @genkit-ai/flow dotenv如果要用本地模型,还需要装对应的插件。我用的是Ollama:
npm install @genkit-ai/ollama初始化Genkit配置:
import { genkit } from '@genkit-ai/core'; import { ollama } from '@genkit-ai/ollama'; export const ai = genkit({ plugins: [ollama({ models: [{ name: 'qwen2.5' }] })], model: 'ollama/qwen2.5', });这里有个坑值得提醒:Ollama插件的模型名称必须和本机Ollama里拉取的模型名称完全一致。我曾经因为大小写不一致,折腾了整整半天。你先在终端执行ollama list看看可用模型的确切名称,再写进配置里。
3.2 定义工具:代理的“手”
代理光有脑子不够,还得有手。我项目里定义了两个工具,一个用于查询用户信息库,一个用于生成Markdown表格。
工具的定义方式如下:
import { tool } from '@genkit-ai/tool'; import { z } from 'zod'; const getUserProfile = tool( { name: 'getUserProfile', description: '根据用户ID查询用户的偏好信息,返回偏好标签列表', inputSchema: z.object({ userId: z.string() }), outputSchema: z.array(z.string()), }, async ({ userId }) => { // 实际场景中这里查数据库,我调试时先用模拟数据 return ['偏好简洁回复', '关注技术效率', '喜欢列表展示']; } );定义工具的时候,最重要的不是函数体怎么写,而是 description 和 inputSchema 怎么描述。模型是靠这段描述来决定“要不要调用这个工具、传什么参数”,描述得越清楚,代理的行为越可控。我见过很多人栽在这上面,工具函数写得没问题,但描述太含糊,模型就频繁误调用。
3.3 构建多回合代理Flow
现在到了最核心的部分:构建一个支持多回合的代理Flow。
import { flow } from '@genkit-ai/flow'; interface AgentInput { userId: string; sessionId: string; message: string; } export const agentFlow = flow( { name: 'multiTurnAgent', inputSchema: z.object({ userId: z.string(), sessionId: z.string(), message: z.string(), }), outputSchema: z.string(), }, async (input) => { const session = await loadSession(input.sessionId); session.history.push({ role: 'user', content: input.message }); let finalResponse = ''; let round = 0; const maxRounds = 5; while (round < maxRounds) { round++; const response = await ai.generate({ model: 'ollama/qwen2.5', messages: session.history, tools: [getUserProfile, renderMarkdownTable], }); // 检查模型是否要求调用工具 const toolCalls = response.toolCalls; if (!toolCalls || toolCalls.length === 0) { finalResponse = response.text; session.history.push({ role: 'model', content: finalResponse }); break; } // 执行所有工具调用,把结果追加到历史 for (const toolCall of toolCalls) { const result = await executeTool(toolCall); session.history.push({ role: 'tool', name: toolCall.toolName, content: JSON.stringify(result), }); } } await saveSession(input.sessionId, session); return finalResponse; } );这段代码是整个项目的骨干,有几处细节我特意标注一下:
第一,maxRounds是必须的。模型在某些场景下可能会陷入“不停调用工具”的循环,比如它反复调用同一个工具来确认一个已经查到的信息。没有上限,你的费用和延时都会失控。我设的是5,实际业务你可以按需调。
第二,executeTool是我做的一个统一执行入口,核心逻辑是:根据 toolCall 里的工具名,找到对应的工具,把参数传进去,返回结果。Genkit 在最新版本里也提供了直接执行工具的方法,但我在项目里保留了这层封装,方便做日志和审计。
第三,把工具执行结果塞回session.history时,角色一定要标记为tool,而不是user或model。模型对角色非常敏感,角色搞错了,下一轮它就会混淆信息来源。
3.4 会话持久化:让“多回合”跨请求生效
Flow本身是无状态的,每次请求进来都是一次新的函数调用。要实现真正的多回合,必须把会话状态存到外部。
我用的方案很简单:本地文件存储,每个会话一个JSON文件。
import { readFile, writeFile } from 'fs/promises'; import path from 'path'; const SESSION_DIR = path.join(process.cwd(), 'sessions'); async function loadSession(sessionId: string) { try { const raw = await readFile(path.join(SESSION_DIR, `${sessionId}.json`), 'utf-8'); return JSON.parse(raw); } catch { return { history: [], createdAt: new Date().toISOString() }; } } async function saveSession(sessionId: string, session: any) { await writeFile( path.join(SESSION_DIR, `${sessionId}.json`), JSON.stringify(session, null, 2), 'utf-8' ); }这个方案在小规模场景下完全够用,而且调试非常直观——你随时可以打开JSON文件,看看模型上一轮到底收到了什么、回复了什么、中间调用了哪几次工具。如果你要上生产,把readFile/writeFile换成数据库操作即可,接口可以保持不变。
有一个经验之谈:会话ID怎么生成,直接影响后续的运维复杂度。我建议用用户ID + 时间戳,或者直接UUID。但要注意,一个用户可能同时开多个会话,所以千万不要只用用户ID当会话ID,否则两个对话会被搅在一起。
3.5 端到端跑通后的效果
我实际跑了一遍完整流程,模拟的发问路径是:
- 用户第一轮说:“我是个注重效率的人,帮我整理一份本周计划。”
- 代理先调用
getUserProfile拿到偏好标签,发现用户“喜欢列表展示”,于是调用renderMarkdownTable把计划渲染成表格。 - 用户第二轮追问:“删掉周三的会议,换成读书时间。”
- 代理此时已经记得第一轮的计划内容,直接基于现有表格做修改,返回更新后的Markdown表格。
这个结果说明,多回合代理的“记忆”和“工具调用”配合发挥出了作用。第一轮的工具调用结果被存入了历史,第二轮模型才能基于真实数据继续操作,而不是凭空想象出一个新表格。
4. 常见问题与排查技巧实录
4.1 模型“上轮已经问过,这轮又问”怎么办
这个问题多发生在工具调用结果没有被正确写入历史时。排查顺序:先看会话JSON文件,确认工具消息是否真的存在;再看角色字段是否为tool;最后确认你执行完工具后没有break掉循环。
我碰到过一次非常隐蔽的bug:因为异步调用顺序问题,工具执行结果还没返回,模型下一轮的请求就已经发出去了,导致看起来“模型失忆”。解决方案就是严格保持while循环内的同步顺序,确保工具结果已 push 进历史,再进入下一轮生成。
4.2 本地模型工具调用不稳定
本地小模型的工具调用能力确实不如大模型,这是硬伤。我实测下来,7B级别的量化模型,有时会对不存在的工具名“幻觉”出一个调用;有时则干脆忽略工具,直接给答案。
应对策略有两个:
- 在工具的description里写得更详细,最好带上“当用户提到XX关键词时,你应该调用此工具”这类指令性描述。
- 在Flow里增加模型回复的校验,如果检测到工具名不存在,直接拒绝执行,并把错误信息作为tool消息返回给模型,让它修正。
这个“错误回灌”技巧非常有用:模型发现自己调用了不存在的工具,会自己纠错。原理就是利用多回合上下文,把模型的错误变成下一轮的输入,让模型自我修正。
4.3 上下文过长导致费用飙升
多回合最大的隐形成本就是token消耗。我建议对你的会话历史做“窗口截断”:只保留最近5轮完整对话 + 一个系统级摘要。如果你用的是云端模型,这个优化能帮你省下大量费用。
我的粗粒度经验数据:一个5轮以内的调试会话,用本地模型几乎无感;但如果几百个用户同时走云端模型,历史管理做不做,每月的成本差距可能是几十倍。
4.4 工具参数校验失败
模型生成的工具参数,本质上是“根据你的schema猜出来的”,所以偶尔会传错类型。比如 userId 该是字符串,模型可能传成数字。最稳妥的做法是:在你的工具函数体里,对输入参数做二次校验,不要完全信任模型。
我在项目里用Zod做输入校验,校验失败时返回一个友好的错误消息给模型,让它重新生成参数。这个做法看起来多了一步,但能避免很多线上低级的运行时异常。
5. 本地模型 + 代理API的整合心法
5.1 让本地模型成为“开发期的常驻助手”
我在这个项目里把“本地模型 + 代理API”的组合当成了日常开发标配。你不需要每次改一行prompt就跑到云端花钱调试,本地模型虽然智力水平有限,但调试流程的正确性完全够用。
只有当本地模型表现出“逻辑能力不足”的时候,我才会切换成云端模型做验证。判断标准是:如果本地模型给出的结果不是错误,而是“看起来合理但根本不是用户想要的”,那就说明这个任务对推理能力要求超出了本地模型的范围,该换大模型了。
5.2 一套代码适配多种模型
Genkit在模型抽象层做得比较干净,切换模型不需要改业务逻辑。我的项目在agentFlow里通过一个环境变量来控制使用哪个模型:
const modelName = process.env.AGENT_MODEL || 'ollama/qwen2.5';把这个变量嵌入ai.generate调用点的model字段,环境变量一换,本地调试和生产部署用不同的模型,互不干扰。
这里有一个额外的收益:当你用本地模型把全流程逻辑调通后,切到云端模型,你会发现云端模型对工具的理解更精准、生成的内容质量更高,而且因为历史消息管理已经做好了,切换过程几乎没有“适配成本”。
5.3 组合后的项目效果总结
我用这个项目做的最有价值的一件事,是搭了一个“资料检索 + 格式化输出的多回合助手”。用户在第一轮描述需求,代理调用工具检索,第二轮提出修改意见,代理基于第一轮的检索结果做调整。整个过程全部通过Genkit的代理API闭环完成,本地模型负责日常调试,云端模型负责最终效果,一套代码覆盖两种场景。
说实话,初学阶段如果直接上云端API,很容易因为“反复调优烧钱”而心态失衡;但用“本地模型先跑通,云端模型做验证”的心态,整个开发过程就变得踏实很多。这也是我这个项目最想传达的经验。
6. 项目最终形态与扩展建议
6.1 代码结构回顾
我这里把最终的项目结构列出来,方便你对照:
multi-turn-agent/ ├── src/ │ ├── index.ts # 入口,启动本地服务 │ ├── agent.flow.ts # 代理主流程定义 │ ├── tools/ # 工具定义目录 │ │ ├── user-profile.ts │ │ └── render-table.ts │ ├── session-store.ts # 会话持久化 │ └── config.ts # 模型与常量配置 ├── sessions/ # 运行时生成的会话JSON目录 ├── .env # 环境变量 └── package.json这个结构我特意保持了“小而清晰”的风格。代理项目跟传统后端不同,它的核心逻辑都集中在agent.flow.ts里,工具是外挂的能力模块,会话存储是独立通用模块。后期扩展时,你只需要往tools目录里加新工具,调一下agent.flow.ts里的工具列表,其他代码几乎不用动。
6.2 还可扩展的功能方向
目前这个项目的核心是“多回合对话 + 工具调度”,但基于这套骨架,后续可以做不少增强:
- 把会话存储换成分散式数据库,支持横向扩容。其实我上面已经留好了接口,把读写换成数据库查询即可。
- 增加会话过期清理。现在会话文件会一直累积,建议写个定期清理任务,比如30天无访问的会话直接删除。
- 增加“人审”环节。在模型调用工具前先弹出一个确认框,适合工具本身有较高风险或成本的场景。
- 接入更多模型提供商,比如用带搜索增强的云端模型做最终回答聚合。
不过这些都属于“锦上添花”,核心的多回合代理逻辑已经完整跑通。
6.3 最后一个小建议
在整个开发过程里,我最想强调的其实是“观察者模式”:多回合代理最大的敌人是黑盒。你如果每一步都看不到模型在做什么,出了问题就没法排查。所以我建议无论你用Genkit还是别的框架,一定要让每一步的输入输出、工具调用记录、历史消息变化都留痕。把“看得见”这件事做好,多回合代理的开发难度会直接降一半。
就我目前的使用体验来看,Genkit的代理API搭配本地模型,是一套性价比非常高、很适合个人开发者和中小团队的技术组合。希望这篇基于实操的记录,能帮你在构建自己的多回合AI代理时少走一些弯路。