前一阵我们用原生大模型调用做了个客服问答机器人,单轮对话效果相当唬人,用户问一句我们答一句,都觉得很顺畅。结果一上内部测试就翻车了:用户连续追问"上个月坏单率多少"、"哪个渠道最高"、"能按天给我拉一张表吗",系统直接懵掉——每次请求都是无状态的,上下文丢了,工具结果也没记住,更别提多人同时用的时候串话串得没法看。
后来我把这条链路整体迁移到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 成本复盘:一次多回合对话到底烧了多少token
Trace里每一轮内部调用都记录了token消耗,这里是做过几次多回合Agent项目之后成本控制的核心。注意一个事实:用户问一句话,如果你的代理中间调了三次工具,那就意味着模型服务被调用了不止一次——每一次工具调用前后都有一次完整的LLM推理。所以"一次对话"的成本,是"一端对话的token成本"乘以"单回合内LLM调用次数"。
看到这个数字后,我的策略是:能用状态摘要的地方绝不把全文丢进历史;工具返回结果做精简;用户消息里的非关键内容也不用原样保留。多回合不是不要成本,而是要把成本花在刀刃上。
从我个人的实操感受来讲,多回合AI代理这个事,难点从来不在"调通模型",而在"状态怎么组织、历史怎么取舍、错误怎么兜底"。Genkit的代理API把这些东西从框架层面帮你立住了,剩下的就是你业务逻辑的编排了。如果让我给一个建议,那就是:不要急着堆功能,先用一个最小的会话循环,把工具、状态、Trace这三件套跑顺,再往上加需求。这样走,多回合代理基本不会烂尾。