多回合AI代理实战:基于Genkit的会话状态管理与工具编排

发布时间:2026/9/29 5:20:16
多回合AI代理实战:基于Genkit的会话状态管理与工具编排
前一阵我们用原生大模型调用做了个客服问答机器人单轮对话效果相当唬人用户问一句我们答一句都觉得很顺畅。结果一上内部测试就翻车了用户连续追问上个月坏单率多少、哪个渠道最高、能按天给我拉一张表吗系统直接懵掉——每次请求都是无状态的上下文丢了工具结果也没记住更别提多人同时用的时候串话串得没法看。后来我把这条链路整体迁移到Genkit的代理API上重写才真正理解多回合AI代理和调一个LLM接口返回文本之间隔着多远。Genkit是Google开源的一个AI应用编排框架它的代理API提供了一整套面向会话的抽象回合turn、会话conversation、工具调用的回写、状态隔离全都替你管好了。这篇就围绕多回合这个话题把我从踩坑到跑通的完整过程拆开讲包括概念、可复现代码、以及生产环境里必须考虑的记忆和隔离问题。适合那些已经能跑通单轮demo但还没想清楚连续对话、带工具、有状态的Agent到底该怎么落地的开发者。1. 为什么多回合是Agent从Demo到可用的分水岭1.1 单轮调用看起来简单真问题都在第二轮之后单轮调用之所以受追捧是因为它足够简单用户一句话进来拼上系统提示词丢给模型拿到文本返回完事。没有状态要维护没有工具要串联出了问题也好定位就是输入输出不对嘛。但真实业务没这么温柔。以我们当时的客服场景为例用户最常干的事是连环追问第一问帮我查一下最近一周支付失败订单的数量。第二问其中有百分之多少是余额不足第三问那把这些订单按渠道分布整理成表格给我。如果每次请求都重新构造上下文第二问的时候模型根本不知道第一问你查过什么第三问的时候连支付失败订单这个集合都丢了。你只能在应用层自己维护一个消息历史数组把之前所有的问答记录一股脑拼进去再把工具调用的结果也塞进去然后祈祷模型能从一堆历史里准确找到该用的信息。这就是典型的表面繁荣。单轮的能力边界只要稍微往前跨一步所有的复杂度都会转移到开发者自己身上。1.2 没有专门的状态管理你会撞上三堵墙自己维护消息数组第一版还能跑第二版开始就会撞墙。我归纳了三类最典型的第一堵墙是上下文丢失。工具调用的结果如果只是拼在历史里模型往往看到了但没用上。比如第一次调用了订单查询接口返回了一个很大的JSON第二次提问时模型会把这段JSON当成普通历史文本而不是当前任务的数据底座于是答非所问。这个问题在长对话里尤其严重。第二堵墙是并发串线。两个人同时用你的服务如果会话状态存在一个共享的全局变量里A用户改了这个状态B用户再发消息就会拿错上下文。有人会说用sessionId存Redis不就行了——确实行但你要自己处理session创建、过期、恢复、清除代码量蹭蹭往上涨。第三堵墙是过程不可见。单轮调用你还能打开日志看输入输出多轮加上工具之后一次请求可能经历了用户消息→模型决定调工具→工具返回→模型再推理→再调工具→最终回复这么一串内部过程。你手写的代码如果没有完整的链路日志出了问题就只能靠猜而猜AI为什么这么答是世界上最没有确定性的事。1.3 为什么要用框架而不是自己造轮子很多人第一反应是自己封装一套消息队列和上下文管理我当时也这么想。但仔细一盘点要处理回合生命周期、要记录工具调用中间结果、要把工具结果正确地放回对话历史、要支持多会话隔离、还要留观测接口——这套东西写下来比业务本身还复杂而且大概率会有一堆边界bug。Genkit代理API的定位恰恰是把回合变成一等公民。它不是在LLM调用外面包一层函数而是给了你一个完整的会话模型每个会话里有多少个回合每个回合的内部经过了哪些步骤工具结果写到哪状态存到哪全部是框架行为不需要你每次自己拼字符串。2. 回合与对话Genkit代理API给出的状态管理答案2.1 一个回合不是我说一句你回一句这是我在项目里花最长时间纠正团队认知的一点。很多人天然以为回合就是用户消息助手回复这样的对话对其实在代理场景里完全不是。一个完整的回合turn应该包含用户发来的消息、模型在这个消息上做的推理过程、过程中发生的若干次工具调用及结果、以及最后的回复。如果代理为了完成用户的一个问题连续调用了三个工具那么在框架里这是一个回合内部的三步工具循环而不是三个回合。这个区分很重要。因为如果你把工具调用也当成独立回合去管理对话历史会非常混乱模型可能会把工具返回的数据误当成用户说的事实依据权威性就乱了。Genkit的做法是把它们作为回合内部的子步骤看待工具调用结果有自己的标记不会和用户消息混在一起。2.2 会话对象贯穿多轮交互在Genkit的代理API里核心入口是这样的流程定义一个代理然后通过代理创建会话对象后续所有消息都通过会话对象发送而不是重新创建一个干净的上下文。当时我们代码里的主流程大致是这个结构用TypeScript写的具体API名称以你用的版本为准import { genkit } from genkit; // ... 其他导入 const ai genkit({ plugins: [/* 模型插件比如谷歌生成式AI或OpenAI兼容插件 */], }); // 定义一个代理给它绑定模型和工具 const agent ai.agent({ name: supportAgent, systemPrompt: 你是一个客户支持助手可以查询订单数据..., tools: [queryOrders, getChannelStats], model: /* 你选择的模型 */, }); // 多回合入口 const conversation agent.startConversation(); let result await conversation.sendMessage(查一下这个月支付失败的订单有多少); let result2 await conversation.sendMessage(其中余额不足的占比是多少); let result3 await conversation.sendMessage(按渠道列个表给我);这背后发生的事情是会话对象自动维护了历史每一轮sendMessage都不是孤立的请求而是带着之前所有回合的状态过去的。第一次查询的结果如果被模型引用过第二轮模型就会记得我们已经查过订单了当前聊的范围是支付失败的集合。2.3 代理会话和普通聊天机器人到底差在哪我用这样一张表向团队解释普通聊天和代理会话的差别维度普通聊天机器人Genkit代理会话历史记录只是消息文本的拼接结构化回合消息、工具调用、工具结果、状态工具结果不由框架管理靠开发者塞进prompt自动作为工具回合记录带明确的角色边界状态隔离基本靠session手工管理每个会话对象独立天然不串线可观测性只有模型输入输出日志每个回合的内部步骤都可以追踪恢复能力通常从头再来会话可以持久化恢复取决于存储配置所以选择代理API不只是在代码结构上省事更关键的是把多轮对话该有什么不该有什么这件事从你脑子里搬到了一个成熟的模型里。3. 最小可运行的多回合代理搭出第一版3.1 环境准备别在这些小事上翻车我假设你已经装了Node.js 18以上npm可用。Genkit本身是语言无关的编排框架但最成熟的客户端是TypeScript版本下面都以TS为例。安装genkit本身很简单npm install genkit接着你肯定需要一个模型提供方。可以接Gemini也可以接OpenAI兼容接口或者自建本地模型Genkit都提供对应适配插件。我自己当时是为了统一内部标准接的是一个OpenAI兼容网关代码里是通过插件的baseUrl配置指过去的。不管用哪家有两件事别省第一API Key千万别硬编码提交到代码仓库用环境变量读。我们团队就有人把key写进配置然后推到git仓库第二天账单上多出两百多块。第二模型版本选固定版本不要选默认的最新版。多回合场景对模型行为一致性要求很高模型悄无声息升级可能会让你的Agent在中间某一步突然不调用工具了排查起来非常痛苦。3.2 工具是代理的手JSON Schema是握手的密约定义了模型之后你的代理还不会干活它得有能力调外部系统。在Genkit里外部能力通过工具暴露给模型工具定义的核心是名称、描述、输入Schema、输出内容。这里有一个我从没在官方文档里看到细讲、但血的教训输入Schema写不好模型就会在调用工具这一步反复出错。比如你让模型生成参数结果字段类型定义错了模型每次都会生成一个数字而你的函数期待一个字符串于是每个回合都在报错。我当时的工具大概是这种形态import { z } from genkit; const queryOrders ai.defineTool({ name: queryOrders, description: 查询订单数据。用于回答用户关于订单量、金额、渠道、时间区间的问题。, inputSchema: z.object({ startDate: z.string().describe(开始日期格式YYYY-MM-DD), endDate: z.string().describe(结束日期格式YYYY-MM-DD), channel: z.string().optional().describe(渠道过滤比如 ios、android), }), outputSchema: z.object({ totalOrders: z.number(), failedOrders: z.number(), channelBreakdown: z.any(), }), async fn(input) { // 真实业务里这里会查数据库或者请求内部服务 return mockQueryOrders(input); }, });注意description写得越具体越好。模型没有读你数据库结构的能力它唯一能参考的就是description和字段的describe描述。3.3 组装一个能连续对话的Agent有了工具就可以把它们绑到一个代理上const agent ai.agent({ name: multiTurnAgent, systemPrompt: 你是一个数据分析助手。你可以查询订单数据来回答用户的问题。 注意用户的问题可能依赖前面回合的查询结果所以请结合上下文判断。 , tools: [queryOrders, getChannelStats], model: myModel, });跑起来之后我建议第一件事不是直接上业务而是开Genkit自带的Dev UI在交互台里连续问几个问题验证代理能不能正确地调工具、能不能把前一轮的工具结论用到后一轮。这一步要是不验证后面所有bug你都会分不清是工具问题还是模型问题。3.4 最小链路跑通后我观察到的三个现象第一代理确实记住了前一轮的工具调用结论。我问查一下这周订单量它调了工具紧接着又问那失败率呢它没有重新从零开始而是基于上一轮的查询上下文去做二次推断。这是多回合AI代理的核心能力。第二不是每次消息都需要调工具。用户如果只是说谢谢代理会基于已有上下文用普通回复接口应答不会没事乱调工具。这一点在框架里是自动的但也意味着你要有trace能力去观察它每一次的决定。第三工具调用失败时代理不会立刻崩溃。它会拿到错误信息然后尝试换一种方式重新描述问题或者向用户澄清。第一次看到这个行为的时候我有一种它活了的错觉。4. 完整实战连续追问的数据报表代理是怎么跑起来的4.1 需求拆解用户要的不是问答是一份渐进收敛的报表我拿一个更贴近业务的实际例子来拆。假设内部有一个订单查询接口想做一个代理让运营同学直接对话拿数据。运营的提问习惯通常是渐进式的第一轮本月订单总量是多少第二轮只看华东地区的。第三轮按天聚合给我一个CSV格式的下载链接。这个需求难就难在只看华东地区的这句话本身是残缺的它没有主语依赖前一轮的本月订单总量这个查询上下文。如果每轮都独立处理代理根本不知道只看华东地区是看什么的华东地区。4.2 关键设计用会话状态存半成品查询条件我的解决方案是代理内部维护一个状态对象里面存当前已经确定下来的查询条件包括时间范围、地区和粒度。每一轮用户提问时代理先读取现有状态再结合用户新消息决定是新增条件、覆盖条件还是重置条件最后调用查询工具。这一步其实解释了多轮代理和普通对话的本质区别普通对话只需要记住说过什么多轮代理需要记住任务推进到了哪一步。这个任务进度用Genkit的话说就是会话状态。4.3 代码路线参考由于版本差异我这里给一个简化版流程示意重点看编排逻辑let state { dateRange: null, channel: null, groupBy: day, filtersApplied: [] }; async function handleUserMessage(message: string) { // 先让模型基于当前state判断用户意图 const intent await model.intentClarify(message, state); if (intent.action SET_FILTER) { // 更新state里的过滤条件 state mergeState(state, intent.patch); } else if (intent.action RESET) { state emptyState(); } // 然后让代理拿着最新state去查数据 const result await agent.sendMessage(message, { sessionState: state }); return result.text; }实际项目里我更建议把意图判断和工具查询都放在同一个代理里完成让模型决定要不要改状态。我上面拆开只是为了讲清楚状态这个环节。4.4 现场踩的两个意外第一个意外工具返回结果太长第三轮模型开始失忆。我们查询接口返回的JSON动辄几十KB模型第一轮还能正确引用第二轮开始就把工具结果当噪声忽略了。解决办法不是让模型更努力记住而是让工具函数在返回前先做摘要只返回与当前问题相关的统计值。数据量大时摘要这一步不能省。第二个意外用户中途改需求旧状态没清干净。运营同学说按天聚合接着又说不对还是按渠道吧。如果状态合并策略是追加那最后查询就会同时按天又按渠道数据对不上。最后我们的策略改成任何新的groupBy字段都会覆盖旧的而不是追加。这个看似细小的规则直接决定了一个多轮代理是好用还是处处出错。5. 多回合上生产的三个关键记忆容量、会话隔离与异常兜底5.1 多回合历史的膨胀问题不是所有轮次都该留着多回合听起来美好代价却是上下文token消耗线性增长。每一轮sendMessage背后其实都会把之前所有消息、工具调用、工具结果重新发给模型。典型场景下用户聊个十轮上下文就轻松超过数万token账单也随之上去了。我的处理办法是给会话设置轮次上限超过上限就把早期的对话做摘要用一段之前我们做了这些事的总结代替原始消息。这样做有两个好处一是token稳定二是模型注意力不容易被历史细节干扰。估算上有一个简单公式可以参考一个回合的平均token消耗大约是用户消息长度工具结果长度模型回复长度的总和。你可以先统计单轮平均值再乘以你想保留的轮次上限就能估算出最差情况下的token成本。留轮次老不留又怕丢信息摘要就是平衡点。5.2 会话隔离用会话ID把谁跟谁彻底分开一旦代理开始服务真实用户你就不能只有一个conversation对象。每个用户进来都要有一个独立的会话。我用的方案是用户登录后按userId生成一个稳定的会话ID没有登录的场景比如匿名客诉按设备标识随机串生成。然后把会话对象和这个ID绑定每次用户发消息都通过会话ID取出对应的conversation然后sendMessage。这在Genkit里本身不复杂因为conversation对象可以序列化存储。我团队当时把会话状态存到Redis里这样服务重启、多实例部署都不会丢会话。需要提醒的是这个持久化会话的动作一定不能省否则上线后服务一重启所有用户对话历史全没了那种事故非常糟糕。5.3 异常兜底让工具错误变成代理的成长经历工具调用没有不出错的关键在于如何不摔碎整个回合。我踩过的坑是工具函数内部抛异常直接让整个请求500用户看到内部错误四个字体验归零。正确的做法是在工具函数内部捕获所有异常把错误信息以字符串形式返回而不是抛出。这样模型可以把查询接口超时了没有这个渠道的数据这类信息读进去然后用自然语言回复用户暂时查不到换个条件试试这个过程对用户来说代理是在正常交流而不是崩了。超时控制也要单独做。我设的是10秒超过就返回查询超时请稍后重试或缩小范围这样的提示文本让模型继续走回复逻辑而不是傻等。6. 调试多回合代理别靠猜靠Trace里的一手现场6.1 Dev UI的Trace是行车记录仪多回合代理最大的调试难点是你很难把这一轮回答跑偏了归因到某个具体环节。是模型理解错用户输入是工具返回的数据不对还是工具结果被模型忽略这种情况下任何形式的心智推理都不如直接看Trace。Genkit的Dev UI会把每个回合内部的完整链路展示出来用户消息、模型收到的是什么prompt、模型决定调哪个工具、工具输入参数是什么、工具返回结果是什么、最终回复基于哪些内容生成。我调试那个报表代理时70%的问题都是靠这个定位的。有次模型第三轮开始不调工具了我看Trace才发现是工具描述里少了一句该工具可以基于前一轮结果进一步筛选模型压根不知道这个工具是用来做二次查询的。改完描述问题立刻消失。6.2 模型明明有工具就是不调用先在Trace里查这四处按照我的排查顺序第一看模型收到的那一轮prompt是否真的包含了工具定义。有时候是代码注册了工具但代理定义里没挂上去。第二看工具描述是否和当前用户意图匹配。模型不调用工具经常是因为描述写得太窄它没意识到这个工具适用于当前问题。第三看工具函数本身是不是报错了。如果工具报错被框架拦截模型可能就绕开工具直接回答了。第四看是不是上下文超长导致工具定义被截断了。这个问题在长对话里偶发把工具定义放在prompt中最靠前的位置能缓解。6.3 成本复盘一次多回合对话到底烧了多少tokenTrace里每一轮内部调用都记录了token消耗这里是做过几次多回合Agent项目之后成本控制的核心。注意一个事实用户问一句话如果你的代理中间调了三次工具那就意味着模型服务被调用了不止一次——每一次工具调用前后都有一次完整的LLM推理。所以一次对话的成本是一端对话的token成本乘以单回合内LLM调用次数。看到这个数字后我的策略是能用状态摘要的地方绝不把全文丢进历史工具返回结果做精简用户消息里的非关键内容也不用原样保留。多回合不是不要成本而是要把成本花在刀刃上。从我个人的实操感受来讲多回合AI代理这个事难点从来不在调通模型而在状态怎么组织、历史怎么取舍、错误怎么兜底。Genkit的代理API把这些东西从框架层面帮你立住了剩下的就是你业务逻辑的编排了。如果让我给一个建议那就是不要急着堆功能先用一个最小的会话循环把工具、状态、Trace这三件套跑顺再往上加需求。这样走多回合代理基本不会烂尾。