
上个月我在做内部知识库问答助手时被一个看似简单的问题卡住了用户说“帮我查一下上周五的会议纪要在哪个目录”助手准确找到了但紧接着用户补充“顺便把相关批复也给我”——助手懵了。它完全不记得上下文里刚才提过哪个会议。这个问题让我认真研究起了多回合AI代理的实现方式最终选定了Google开源的Genkit框架用它的代理APIAgent API把整个链路跑通。过程中踩了不少坑从框架选型、本地模型接入到会话状态管理都有很多值得聊的细节。这篇就把我从零搭到跑通多回合AI代理的完整过程写出来适合刚接触代理开发、或者正在做对话式AI应用的开发者参考。1. “多回合”到底难在哪两个容易混淆的层级先说清楚一个前提很多人以为“多回合”就是聊天记录拼接。用户每说一句把历史对话全部塞给模型模型就能回答。这个理解错了一半。真正生产级的多回合AI代理面临的是两个不同层级的问题。1.1 语言模型的记忆和代理运行时的任务状态是两码事语言模型本身确实能做到“多轮对话”的记忆——只要你在请求里带上历史消息模型就能引用前文。但这只是“语言层面的记忆”。它记住的是用户说过的话、你回复过的话是一种纯文本的对话历史。而代理在处理真实任务时还需要维护一套“任务状态”。举个实际例子用户先说“帮我查一下北京到上海明天的高铁”代理调用了查询工具返回了10趟车次用户接着说“不要早于9点的”代理需要知道——当前候选车次是哪些用户关心的是时间范围还是票价如果是一个多步骤任务代理甚至需要维护一个待办的子任务清单查完车次之后还要订票订完票还要提醒加购保险。这些内容有一部分会出现在对话记录里因为用户确实说了“不要早于9点”但大部分不会——查询工具返回的10趟车次、当前选中的是哪一趟、哪些子任务已经完成这些都属于代理内部的状态跟“聊天记录”完全不是一回事。1.2 为什么我选了Genkit而不是自己手写状态机一开始我确实想过自己写。用LangChain或者干脆裸调模型API自己维护一个session结构history数组、taskState对象、工具调用中间结果。写到一半就发现事情越来越多工具调用的参数校验要自己写、模型返回的tool_call解析要自己兼容各家格式、失败重试的循环逻辑要自己设计、日志追踪要从零搭……Genkit吸引我的点是它把“可观测性”和“工具调用”这两个最麻烦的部分直接内置了。尤其是它新推的代理API把session、memory、agent执行循环这些概念做成了框架级的原语。我不用再自己发明一套状态管理只需要关注“我的代理要做什么任务”就行。而且它是Google开源的东西偏向基础设施而非某个云平台的绑定本地模型、各家云端模型都能接这点后面会详细展开。2. 环境准备Node项目 Genkit插件 本地模型跑起来2.1 依赖安装与项目初始化我用的Node.js 20版本TypeScript模式。初始化项目就几步mkdir genkit-agent-demo cd genkit-agent-demo npm init -y npm install genkit genkitx-ollama zod npm install -D typescript tsx types/node这里有个值得注意的版本问题——Genkit的代理API目前仍属于比较新的能力在TS SDK里以实验性接口提供。安装完建议跑一下版本确认npx genkit --version我实际开发时用的是0.9.x这个阶段的版本API相对稳定。如果你安装的版本更新出现接口签名不一致的情况优先以官方文档的agent API部分为准。2.2 接入Ollama本地模型摆脱云端API依赖很多教程默认接Google AI或Vertex AI但我这次的目标场景是内部工具数据不能出内网所以模型必须本地跑。我用的是Ollama因为部署最简单下载安装、拉模型、改一个baseUrl就能用。ollama pull gemma2:9b ollama pull qwen2.5:7b-instruct同时拉了两个模型原因后面踩坑部分会讲——9B模型在函数调用格式的遵循度上参差不齐多准备一个备用模型能大幅节省排查时间。Genkit接入Ollama的方式很直接import { genkit, z } from genkit; import { ollama } from genkitx-ollama; const ai genkit({ plugins: [ ollama({ servers: [{ name: local, baseUrl: http://localhost:11434 }], }), ], model: local/gemma2:9b, // 这里是新版API里额外的代理配置入口 // 具体字段因版本而异下面会提到 });配好之后跑一个最小验证const res await ai.generate({ prompt: 用一句话解释什么是多回合AI代理, }); console.log(res.text);2.3 本地模型的实际表现评估本地模型跑通容易但跑得顺不容易。我用同样一段“调用工具查看天气并回答”的Prompt测试了几款模型模型工具调用格式遵循度中文回复质量实测结论gemma2:9b中中偶尔输出不规范的tool_call适合备用llama3.1:8b高中工具调用稳定可当主力qwen2.5:7b-instruct高好中文场景推荐工具调用规范最关键的一点本地模型的推理速度直接决定多回合体验。模型每回复一轮都要重新跑一遍完整上下文上下文越长生成第一个token的延迟越高。我的实际经验是9B模型在消费级显卡上跑多回合时超过10轮历史后会有明显的“变慢”感。这也是后面设计会话状态时为什么要控制历史条数的原因之一。3. 代理API的核心拼图工具、循环与上下文传递这一节是整篇的骨架。Genkit的代理API说白了就是帮你把“模型工具多轮记忆”组装成一个可调用的代理对象。但它不像某些框架那样给你一个黑盒它把每个环节都暴露给你这一点我特别喜欢。3.1 用defineTool声明代理能用的工具工具是代理的“手和脚”。Genkit里定义工具的方式用zod做输入输出校验最直观import { z } from genkit; const queryMeetingNotes ai.defineTool( { name: queryMeetingNotes, description: 根据日期或关键词查询会议纪要返回文件路径和摘要, inputSchema: z.object({ date: z.string().optional().describe(日期格式YYYY-MM-DD), keyword: z.string().optional().describe(关键词), }), outputSchema: z.object({ files: z.array(z.object({ path: z.string(), summary: z.string(), })), }), }, async ({ date, keyword }) { // 这里调用你实际的数据源比如文件系统、数据库、搜索API return { files: [{ path: /docs/2025-06-13.md, summary: 讨论了Genkit代理API选型 }] }; } );有个经验想分享description字段一定要写得详细。模型决定要不要调用工具、传什么参数主要就看这个描述。我一开始写得特别简单——“查会议纪要”结果模型经常在无关紧要的闲聊里也去触发查询。把描述扩展成“当用户询问会议相关内容时调用可根据日期过滤可根据关键词搜索”触发准确率立刻上来了。3.2 组建代理instructions tools memoryGenkit代理API的重点是组合。它把系统提示词、工具列表、多轮记忆整合到一个代理对象里const assistant ai.asAgent({ name: docAssistant, instructions: 你是一个内部文档助手。用户会分多步提出查询需求。 你需要记住用户当前关注的目标在后续对话中持续关联这些信息。 当需要查询实际数据时调用queryMeetingNotes工具。 如果用户没有说明具体日期或关键词先通过对话确认不要盲猜。, tools: [queryMeetingNotes, searchRelatedFiles], memory: { kind: memory, maxMessages: 20, }, });这里的instructions就回答了1.1里那个问题多回合的“任务追踪”能力从哪来它一部分靠框架层面的session记录让模型能看到历史消息另一部分靠系统提示词引导模型主动维持“当前目标”的认知。很多教程会把全部功劳归给框架但实际体验下来instructions里的目标维持策略同等重要。3.3 创建会话并执行多轮交互接下来是最有意思的部分多回合的调用方式。Genkit引入了一个session概念——每个用户对应一个独立的session代理在session内部维护历史// 用户第一次提问 const session assistant.createSession(); const res1 await assistant.run(session, { prompt: 帮我查一下上周五的会议纪要在哪, }); console.log(res1.text); // 输出找到了是/docs/2025-06-13.md内容是讨论Genkit代理API选型…… // 用户第二次提问——必须能关联上下文 const res2 await assistant.run(session, { prompt: 顺便把相关批复也找出来, }); console.log(res2.text);关键在于第二次调用时Genkit代理API会自动把session内之前的对话历史拼接进模型请求。所以第二次提问才能正确理解“相关批复”指的是“上周五那个会议的批复”。手动实现这个效果也不复杂本质就是每次请求前把history拼进messages数组。但代理API帮你把这个过程封装成了session.run同时帮你处理了历史截断、格式转换这些细节。当你有多个用户并发时只需要给每个用户持有一个session对象状态就自然隔离了。4. 会话状态的设计从“聊天记录”到“任务上下文”框架给你封装了session但不代表你可以完全不关心状态设计。恰恰相反代理API只保证“历史消息能传进去”至于传多少、传什么、怎么防止跑偏这些还是得自己做。4.1 maxMessages不是越大越好代理API提供了maxMessages参数控制保留多少条历史消息。我一开始设了50条觉得越多越聪明。实际跑下来问题不少第一token开销暴涨。本地模型上下文窗口本来就不算宽裕50条消息里掺着大量“嗯”“好的”这类无效内容真正的关键信息反而被稀释了。第二模型注意力偏移。模型性能会随着历史中无关信息增多而下降尤其是本地小模型历史一长就开始“忘事”或者错误关联。我现在一般把maxMessages控制在10到20之间同时做一件事在每轮对话结束后从回应里抽取“关键任务状态摘要”把它作为一段系统级的消息塞回session历史里。举个例子用户订酒店时说了“不要靠马路”我会在后台附加一条隐藏消息“当前用户偏好房间不要靠马路当前候选A酒店、B酒店”。这样即使30轮之后模型依然能通过这句摘要维持对任务的理解而不是依赖淹没在噪声里的原始聊天记录。这个设计是我这次项目里最满意的一个点建议实用主义者直接复刻。4.2 任务上下文的动态更新一个可落地的模式顺着上面的摘要思路我把整个会话状态拆成了三层层级内容存储方式更新时机原始对话层用户和代理的完整对话session的message history每轮自动追加任务状态层当前目标、用户偏好、已选选项作为hidden message注入每轮结束后由代理或程序抽取执行结果层工具调用的返回值、系统操作结果保留最近N条更早的做摘要工具调用后更新这个三层结构帮我解决了一个核心矛盾多回合代理既需要“记住细节”又不能让细节淹没关键信息。原始对话层保证语言上的连贯性任务状态层保证目标追踪的稳定性执行结果层保证后续工具调用还能引用到前几轮查到的数据。代理API本身提供的memory能力覆盖了第一层和部分第三层第二层需要你自己在instructions里引导代理维护或者像我做的那样在代码里独立维护一份结构化状态在每轮请求前注入。两种方式我都试过纯靠提示词更省事但结构化注入更可控生产环境我推荐后者。4.3 多用户并发时的会话隔离这个坑很隐蔽。我第一次跑通单用户后直接写了个HTTP接口用一个全局session对象接所有请求结果用户A问的东西用户B全都看到了。代理API的原生设计是每用户新建session但落实到Web后端你得解决“按用户取session”的问题。我的做法是简单做了一个session注册表const sessions new Mapstring, ReturnTypetypeof assistant.createSession(); function getSessionForUser(userId: string) { if (!sessions.has(userId)) { sessions.set(userId, assistant.createSession()); } return sessions.get(userId)!; }生产环境当然不能用内存Map换成Redis存session序列化数据或者数据库持久化都可以。这里想强调的是会话隔离不是框架自动帮你做好的事情框架保证的是“一个session内部的状态一致”但“哪个session属于哪个用户”得由你来管理千万别写全局单例。5. 实测中的三个大坑与完整排查思路跑SSE流式接口、写前端对话框、接内部知识库……整个项目做下来有四个问题让我花的时间比写功能本身还多。逐个展开说每个都是能复现的坑。5.1 坑一本地模型不返回规范的工具调用参数这是我遇到的第一个拦路虎。搭好代理API后第一次问“查一下上周五的会议纪要”模型确实返回了tool_call但里面的参数是空的——既没有date也没有keyword。排查链路是这样的先在Genkit的开发者UI里看trace确认模型确实走了工具调用分支但参数是空对象。接着我跳出Genkit直接用ollama命令行工具测试同一模型发一样的对话格式发现Ollama原生接口返回的是{tool_calls: null}模型根本没识别出函数调用意图。最后换成qwen2.5:7b-instruct同一段代码立刻返回了规范化参数。根因很清晰不同模型对工具调用的支持程度天差地别。Ollama官方对qwen和llama3.1都标记了工具支持但gemma2:9b在我当时测试的版本里工具支持没有做得很完善。解决方案主力模型改成qwen2.5:7b-instructgemma留在那里做纯文本摘要任务。提示接本地模型做代理开发时务必先做一次“工具调用冒烟测试”——带一个工具问一句明显需要触发工具的话看返回的tool_call参数是否完整。这步过关再往下写业务逻辑。5.2 坑二代理陷入工具重试死循环模型识别到要调用工具了但它调用了一个不存在的参数值。比如用户说“查一下上周的”我用date字段接了“上周”两个字后端解析日期失败抛了个错误。结果模型拿到错误后又尝试调用一次又失败又调用……接口卡了好几分钟。问题根因在代理API的工具执行错误处理策略默认情况下模型收到工具异常后会把异常信息拼进上下文然后自主决定怎么处理。但如果模型不知道怎么修正参数它就会重试同样的错误调用。我的排查过程先看trace发现错误信息确实传回去了但模型没有从“上周”里提取出具体的日期范围。后来我在instructions里加了一句“如果日期模糊先追问用户精确日期再调用工具”问题立刻缓解。另一个兜底做法是在业务代码层面对输入做一次预校验解析失败的参数不让它进入工具函数直接生成一个“参数无效原因……”的标准错误返回。处理重点有两个一是在提示词里明确告诉模型“工具出错了该怎么做”二是加强参数校验让工具层的错误信息对模型更友好、更可操作。5.3 坑三历史消息里杂音太多关键信息被挤丢这个坑跟4.1是同一个问题的不同侧面。具体场景是用户在第1轮说了“上周五”第2轮确认“就是那个会议室讨论的”第3轮才说“帮我找批复”。理论上代理应该能把三层意思串起来但实测模型在第3轮经常把“上周五”给忘了。排查下来是因为前两轮的原始消息被完整保存在session里但第3轮组装请求时模型看到的是一大段连续的聊天记录它需要在其中自行定位“上周五”这个信息。当时正好在做一个对比实验同一段历史A版本直接塞全部记录B版本塞全部记录外加一条任务摘要。差别非常明显B版本的定位准确率远高于A。后来我就把“任务状态摘要”做成了每个回合结束后的必跑步骤代码逻辑大致是async function extractTaskSummary(ai: Genkit, history: any[]) { const summary await ai.generate({ prompt: 基于以下对话抽取当前用户正在进行的主线任务信息 包括当前目标、已完成步骤、用户偏好、待确认事项。 输出简洁的中文要点。\n\n${historyText}, }); return summary.text; }当然这一轮额外调用也增加了成本但对多回合可用性的提升是实打实的。在本地模型场景下这个摘要耗时不长可以接受。6. 跑通之后多回合效果对比与后续扩展方向6.1 单轮与多回合的实测效果对比在同样的内部文档场景下我分别实现了单轮助手和多回合代理测试结果直接印证了最开始的判断。单轮助手就是每次请求都重新调用模型不带session只靠用户自己把上下文说全。测试场景单轮助手多回合代理查会议纪要并追问批复失败追问时丢上下文成功关联到上一个会议分步筛选文档先按日期再按项目需用户重复所有条件一次描述持续筛选用户中断后改需求需要重新说明全部背景从摘要中理解原需求并调整连续处理三个无关查询容易每条独立需要留意上下文串扰比较有意思的是第四行。多回合代理在处理连续多个无关查询时反而有“污染”风险——用户第一个问题问会议纪要第二个问题突然转问员工手册代理可能还会引用之前会议纪要的上下文。这个问题靠instructions可以缓解加了一句“当检测到话题完全切换时主动确认是否开启新任务”。6.2 后续还能扩展的方向持久化、流式输出与评测目前的session存在于内存中服务一重启就丢。要上生产首先是给session做持久化——用Redis存序列化后的历史或者直接存数据库表。Genkit的API目前没有内置持久化实现但它把session的序列化数据结构暴露了出来自己写一个恢复函数不算难。其次是流式输出。多回合下流式会引入一个新问题工具调用和文本生成交织出现前端的展示逻辑要能区分“正在思考”“正在调用工具”“正在回答”三种状态。可以先在console里观察事件流再决定前端怎么渲染。最后是评测。多回合代理的回归测试比单轮难做因为同一句输入在不同历史上下文里回答应该不同。我的建议是把关键业务场景写成“多步剧本”比如“查询纪要→追问批复→改查员工手册”一键跑完整条链路断言最终输出是否达标。这个思路可以后续用Genkit的eval能力逐步固化。最后分享一点实际体会整个项目跑下来我最深的感触是多回合AI代理的难点不在“让模型记住历史”而在于“在长长的历史中持续维持任务认知”。代理API帮我省掉了大量样板代码但真正决定代理好用与否的仍然是状态设计、提示词策略和模型选择这三件事。如果你也正准备用Genkit做代理我的建议很直接先用本地模型把最小闭环跑通再逐步加上任务摘要、会话隔离、持久化这些工程化能力。遇到工具调用不规范的模型别硬扛换一个支持的模型很多问题瞬间消失。代理的路子很长先把多回合的底盘打稳了后面加什么能力都顺手。