1. 为什么大家都在谈“多回合AI代理”
1.1 单轮问答和多回合代理的本质区别
最近大半年,我一直在做企业内部工具、“数字员工”这类东西,一个很明显的感受是:但凡你只做一个“问一句答一句”的聊天机器人,用户新鲜感一过就觉得它没用。真正让人感觉“这玩意儿像个助手”的,是它能连续聊、能记得住前面说过什么、能在对话过程中自己去查数据调工具。这就把话题引到了“多回合AI代理”上。
先说清楚“多回合”到底解决了什么问题。我之前做的第一个版本是典型的单轮问答,用户发一句“我的订单到哪了”,程序把这句话原样拼进提示词,丢给模型,拿到答案就返回。听起来没问题,但它显然不知道“它”指代什么、不知道怎么把上一轮的用户ID带过来、更不会主动想起来“你刚才说要查物流,那现在是不是还要补一句签收提醒”。单轮问答的本质是“无状态”的,每个请求都是孤立的。而多回合代理的核心是“有状态”——代理这边维护一份对话历史,每来一条新消息,都把最新的用户输入和之前的历史一起送进模型,让模型基于完整的对话上下文来做判断和回复。
再加上“能调用工具”之后,价值就完全不一样了。多回合的上下文只是让代理“记得聊过什么”,工具调用让它“真的能干事儿”。两者一叠加,代理才能在对话中间突然说“稍等,我查一下库存再给你建议”,然后真的调一个库存接口,再基于结果继续聊。这就是现在圈子里说的“AI代理助手”最典型的形态。
1.2 Genkit在代理方案里的位置
做这种多回合代理,方案其实不少。你可以直接调各家模型的SDK,自己维护对话历史数组,自己写工具调用循环,自己管理重试和报错。我也这么干过,第一版就是手搓的,API调来调去,toole调用的JSON解析逻辑写了一百多行,还要兼容不同模型的返回格式,维护起来非常痛苦。
后来我切到了Genkit,整体感觉是“框架帮我把脏活累活包了”。Genkit是Google开源的一套AI应用开发框架,它对上层提供了一套很统一的抽象:模型、工具、提示词、数据注入、可观测性都有对应的概念。最吸引我的其实是它的“代理API”,也就是defineAgent这套能力。它把“多回合对话 + 工具调用 + 上下文维护”这个循环给封装好了,我不再需要自己写一个while循环去反复调模型、解析工具调用、执行函数、再把结果塞回上下文。只要定义好代理的指令、模型、工具,框架会在每次调用时自动处理那一整套循环逻辑。
还有一个现实原因:Genkit对模型层做了抽象,切换模型非常省事。我当前在做的这个项目有隐私要求,数据不能出内网,所以推理得跑在本地模型上;但某些不敏感的场景我又想用云端模型对比效果。用Genkit的话,代理的定义不变,只要换一下model参数,甚至能在同一个代理里给模型方案做A/B测试。这种“模型无关”的设计,对我这种经常要在多种部署形态之间横跳的人来说,省下的不只是半小时改代码,而是整个架构层面的统一。
如果你之前没接触过Genkit,也不用慌。它不是那种“必须把整个生态都学完才能动手”的重量级框架。核心概念就四个:model(模型)、tool(工具)、prompt(提示词)、flow/agent(流程/代理)。其中代理API就是我今天要展开讲的主角。
2. 环境准备与模型接入方案
2.1 初始化项目与安装依赖
按我踩过的坑来看,第一步先把Node环境准备好。Genkit的JavaScript SDK要求Node 20+(我用的是Node 22,跑得很顺),装好npm之后直接新建项目目录,然后初始化:
mkdir genkit-agent-demo cd genkit-agent-demo npm init -y接下来安装核心依赖。我这里接的是Ollama跑本地模型,所以除了Genkit本体,还要装上Ollama的插件:
npm install genkit @genkit-ai/ai genkitx-ollama npm install -D typescript tsx @types/node注意一个细节:@genkit-ai/ai里有defineAgent和tool这两个核心函数,genkit这个包则是CLI工具链和运行时。genkitx-ollama是社区维护的Ollama插件,版本更新很快,建议装最新版。装了typescript和tsx是为了能用TS写代码、用tsx直接跑脚本调试,不需要额外配编译步骤。
初始化TypeScript配置,我用的极简配置:
{ "compilerOptions": { "target": "ES2022", "module": "NodeNext", "moduleResolution": "NodeNext", "strict": true, "esModuleInterop": true, "skipLibCheck": true } }这里要提醒一句:TS的module和moduleResolution如果配得不一致,会出现“cannot find module”之类的报错。照上面这对配置来,能少折腾半小时。
2.2 本地模型到底怎么接
“AI代理助手加本地模型”是目前很热的一个方向,说白了就是代理的推理完全跑在自己的机器或内网服务器上,不把对话数据发给外部API。这么做的好处很直接:数据隐私可控、离线可用、长期算成本更低。坏处也明显:本地模型对工具调用(function calling)的支持度不如云端旗舰模型,后面我会具体讲怎么绕。
先说怎么把本地模型接进Genkit。我的方案是Ollama + Qwen2.5系列的模型,因为Qwen对工具调用的支持在开源模型里属于第一梯队,而且中文理解能力不错。
先在机器上装Ollama并启动服务,然后拉取模型:
ollama pull qwen2.5:7b如果你的机器配置一般,7B参数是最合适的起点,显存8GB左右就能跑。显存不足的话,可以试试qwen2.5:3b,但工具调用能力会再弱一截。我之前在一张旧显卡上跑3B模型,连续对话没问题,但让它识别工具参数经常出错,后来还是换回了7B。
模型拉取完成后,在代码里初始化Genkit并注册Ollama插件:
import { genkit } from 'genkit'; import { ollama } from 'genkitx-ollama'; const ai = genkit({ plugins: [ ollama({ models: [ { name: 'qwen2.5', type: 'chat', }, ], // 默认连本地 11434 端口 serverAddress: 'http://127.0.0.1:11434', }), ], });之后在代理里用ai.ollama('qwen2.5')就能引用到这个本地模型。如果你接云端Gemini,只需要换成@genkit-ai/googleai插件,把model参数改成gemini('gemini-2.0-flash')。整个代理定义完全不用动。这就是前面说的模型抽象带来的便利。
下表是我自己在这个项目里对比云端模型和本地模型后的结论,给正在做选型的朋友参考:
| 维度 | 本地模型(Qwen2.5 7B) | 云端模型(Gemini) |
|---|---|---|
| 工具调用可靠性 | 中等,需要写好提示词和工具描述 | 高,开箱即用 |
| 中文多轮理解 | 不错,日常对话够用 | 很强 |
| 数据隐私 | 完全本地,不出内网 | 受厂商数据政策约束 |
| 延迟 | 取决于显卡,7B大概1-3秒/轮 | 网络往返,通常1-2秒 |
| 长期成本 | 一次硬件投入,无增量费用 | 按token计费 |
如果你的场景只是内部工具、数据不能外传,那我强烈建议从本地模型起步。如果以后发现工具调用确实撑不住复杂场景,再考虑把模型参数切到云端,反正代理的代码骨架不用改。
3. 用代理API搭一个多回合客服代理
3.1 先把工具定好
代理API的核心套路是:给代理配好“手和脚”,它才能根据对话内容决定要不要干活。这里的“手和脚”就是我说的工具。我拿一个具体的项目当例子——一个电商场景的客服代理,它需要查订单状态、算运费,然后基于这些信息跟用户连续对话。
在Genkit里定义一个工具,用tool函数,基本结构是这样:
import { tool } from '@genkit-ai/ai'; import { z } from 'zod'; const getOrderStatus = tool( { name: 'getOrderStatus', description: '根据订单号查询订单的当前状态。当用户询问订单发货、物流、签收情况时使用。', inputSchema: z.object({ orderId: z.string().describe('用户的订单号,形如 SO20240501'), }), outputSchema: z.object({ orderId: z.string(), status: z.enum(['pending', 'shipped', 'delivered', 'cancelled']), estimate: z.string().optional(), }), }, async ({ orderId }) => { // 实际业务里这里会查数据库或调后端接口 return { orderId, status: 'shipped', estimate: '预计明天送达', }; } );这里有两个坑,我必须单独说。第一个坑是description一定不要偷懒。工具的名称和描述是模型决定“要不要调用、什么时候调用”的主要依据,描述里要明确写出“什么时候用、用的时候要填哪些参数”。你写“查询订单状态”和“根据订单号查询订单当前状态,当用户询问发货、物流、签收时使用”,模型调用工具的成功率完全是两个级别。第二个坑是inputSchema里的字段describe一定要写清楚。本地模型尤其吃这套,光有字段名不够,得告诉它每个字段在你业务里代表什么、格式是什么,它才知道怎么从对话里提取“SO20240501”这样的订单号。
同样的方式再定义一个运费计算工具。注意,这里的重点不是工具内部逻辑多复杂,而是“代理能不能在恰当的时候想得起来用工具”。所以我把每个工具的边界都写得很窄、很清楚:查订单就是查订单,算运费就是算运费,绝不搞一个模糊的“客服工具包”。
3.2 用defineAgent定义代理本体
工具就绪后,代理本体就很好写了。核心是defineAgent函数,我把刚才那个客服代理定义出来:
const customerServiceAgent = defineAgent( { name: 'customerServiceAgent', description: '电商客服代理,负责解答订单和物流相关问题。', // 使用本地模型 model: ai.ollama('qwen2.5'), tools: [getOrderStatus, calcShipping], instructions: ` 你是"新鲜集市"电商平台的客服助手。 你的工作原则: 1. 保持友好、简洁、专业,中文作答。 2. 用户询问订单状态时,必须调用 getOrderStatus 工具查询,不能凭空编造。 3. 用户询问运费时,必须调用 calcShipping 工具计算。 4. 如果工具返回任何错误,如实告诉用户"系统暂时无法查询",不要尝试编造替代答案。 5. 在连续对话中,要记住用户前面提过的订单号、地址等信息,避免让用户重复提供。 `, }, // 这里可以写自定义逻辑,也可以省略 async (input, ctx) => { return ctx.tools; } );这段代码就是整个项目的核心。注意instructions不是随便写的提示词,它是代理的行为准则,直接决定了多回合对话的表现质量。我写的时候刻意用了“必须……不能……”这种命令式语句,效果比“你可以……”好很多。后来我实测发现,本地模型对“必须调用工具”这类硬约束的遵循度明显高于“可以考虑使用工具”这种软表达。
defineAgent的第二个参数是可选的handler函数。如果你只是想做一个“标准代理”——收到消息、结合上下文和工具自动回复——那不加这个回调也完全没问题。只有当你想在代理执行前后插入自定义逻辑(比如先做权限校验、给回复附加额外数据)时,才需要写这个函数。
每个字段的作用我再点一下名:name是全局唯一的代理标识,description用于在嵌套代理或开发者UI里告诉外部“这个代理是干嘛的”,model是推理引擎,tools是它能调用的工具列表,instructions是系统提示词。最重要的就是instructions,多回合代理的性格、边界、工具使用规范全部写在这里。
3.3 多回合上下文与历史传递
代理定义好了,怎么让它“多回合”起来?答案在返回值的history字段上。每次调用代理后,Genkit会把这次交互的完整历史(包括用户消息、代理消息、工具调用记录)打包返回。下一轮对话时,把这个history原样传回去,代理就具备了跨轮记忆。
我写一个简单的交互循环来演示,这也是最贴合实际使用场景的姿势:
import { history } from '@genkit-ai/ai'; let currentHistory: history.History = []; async function chat(userInput: string) { const response = await customerServiceAgent(userInput, { history: currentHistory, }); // 保存本轮历史,供下一轮使用 currentHistory = response.history; console.log('代理:', response.text); return response.text; } await chat('帮我查一下订单 SO20240501 到哪了'); // 代理: 查到您的订单 SO20240501 已发货,预计明天送达。 await chat('那这个订单还能改地址吗?'); // 代理基于上一轮提到的订单号继续回答,不需要用户再报一次订单号这个设计非常关键。没有history的传递,第二轮问“改地址”时,代理根本不知道“这个订单”指的是哪个订单。有了完整历史,它就能把两轮对话关联起来,甚至在第三轮问“运费呢”时,它一样知道自己还在聊同一笔订单。
关于history,我建议你仔细看一下它的结构。Genkit的history里每条消息都会带上role(user、model、tool等)和对应的内容。这意味着代理不仅能记住用户说了什么,还能记住自己调过哪些工具、工具返回了什么结果。这是多回合代理比“只拼接聊天记录”更可靠的一个底层原因——它在每一轮都能看到完整的“思考-调用-观察-回复”链路,而不是只看到明面上的聊天文本。
实际项目中,你几乎不会用控制台交互,而是会把history存到数据库或Redis里,每个会话对应一份历史,下次用户再来时按会话ID加载。上面那段控制台代码只是帮我们理解原理用的。
3.4 用开发者UI做调试
代码写完之后,我强烈建议你启动一下Genkit自带的开发者UI,它能极大加快调试速度。在项目根目录执行:
genkit start启动后浏览器打开默认的本地端口(一般是4000),就能看到Genkit控制台。左边会列出当前项目里定义的所有flow、agent、tool。我通常在这里先跑几轮对话,观察每次调用的输入输出、token数、耗时,还能看到每一次tool call的完整参数和返回结果。
有个功能我特别常用:在UI里手动编辑历史记录来测试多回合行为。比如我想测“用户第二轮改了订单号,代理能不能记住新订单”,我直接在UI的history里塞一条用户消息,然后运行代理,观察它的回复。这种改历史再跑的方式,比我反复改代码重启要高效得多。另外UI也支持流式响应预览,配合本地模型调优时非常直观,可以看到token是怎么一步步被生成出来的。
4. 实测过程与关键参数调优
4.1 第一次跑通,本地模型给了我一记闷棍
代码写完,第一次在本地模型上跑,效果并不理想。让我印象最深的一个翻车场景是:用户说“查一下SO20240501”,本地模型没有直接调用getOrderStatus工具,而是自己照着工具描述编了个答案:“您的订单正在配送中,请耐心等待。”它把工具定义当成了知识库,以为描述里的示例就是真实数据。
这个问题很典型,尤其是7B这种体量的本地模型,在指令遵循上就是没有云端大模型那么死心塌地。我当时的排查思路是这样的:先看Genkit UI里的调用记录,确认模型到底有没有发起工具调用。结果显示它确实没发起,而是直接在文本里生成了答案。这说明问题不在代码,而在模型对“什么时候必须调工具”的理解上。
我的解决办法是双管齐下。第一,在instructions里把规则写得更死:“只要用户提到订单号并且询问任何与订单有关的信息,第一步必须调用 getOrderStatus,不允许跳过工具直接回答。工具返回什么,你就回答什么。”第二,在工具描述里把“这是工具,不是答案”的边界讲清楚:“该工具返回的是当前订单最新状态,回复必须以该结果为准。”同时我还加了副标题式的强调词,这些策略对中小体量模型很管用。
这样调完之后,成功率高了很多,但还没有到“次次都对”的程度。后来又过了一轮,我发现偶发情况下模型会调用工具,但把参数里的orderId填成了“用户说的那个订单号”而不是“SO20240501”这种具体值。这就是参数提取能力不足。我的对策是在inputSchema里对每个字段的描述写得更细:
inputSchema: z.object({ orderId: z.string().describe( '用户提供的原始订单号字符串,必须原样提取,格式为 SO 开头后跟8位数字。若用户未提供订单号,先向用户询问。' ), }),加了“若用户未提供,先询问”这句之后,代理总算学会了:参数不全时先问人,而不是硬编一个假参数。这个“先问再调”的行为,其实是多回合代理特别有价值的一点——它能把双方的对话继续下去,而不是一次调用失败就冷场。
4.2 上下文管理:历史越长,既要记得住也要控得住
多回合代理做到第5轮之后,我很快遇到第二个问题:代理开始“忘事儿”,而且响应速度明显变慢。这里要解释一下底层原因:Genkit的代理API在每轮对话时,会把之前所有的历史消息全部拼进本轮请求发给模型。历史越长,请求里的token就越多,本地模型推理时间自然变长,还可能超出模型的上下文窗口,或者让模型在冗长的历史里抓不住关键信息。
最直观的症状是,聊到第8轮,用户问“那我刚才说的地址还记得吗”,代理回复“抱歉,您没有提供过地址”。查日志发现,那条地址信息其实在后几轮被挤出了有效的注意力范围。这不能怪框架,是我没有做历史的裁剪和压缩。
我的处理方案是给历史管理加了一个简单的策略层。用数组保存每轮历史,超过N轮就把最早的部分截断,只保留最近N轮。同时,如果截断丢了“关键事实”(比如收货地址、订单号),我会把这些事实单独抽出来放进一个“业务记忆”字段,在每一轮作为额外的系统提示注入。这样既控制住上下文长度,又不丢失真正重要的信息。用代码表示就是:
const MAX_TURNS = 6; function trimHistory(h: history.History): history.History { // 只保留最近 MAX_TURNS 轮的用户消息和代理回复 return h.slice(-MAX_TURNS * 2); } const businessMemory = { orderId: 'SO20240501', address: '杭州市西湖区...', }; const response = await customerServiceAgent(userInput, { history: trimHistory(currentHistory), context: { businessMemory, }, });注意我在调用时传了context。这是代理API里一个很实用的字段,你可以把业务侧需要长期记住的数据放进去,它和普通对话历史分开管理,不会因为历史截断而丢失。这个模式我强烈推荐给所有做多回合代理的人:对话历史负责“聊天的连贯性”,context负责“业务的持久性”,两者各管各的,别搅在一起。
另外还有一个性能优化点:不是每一轮都需要模型重新“看”一遍所有工具描述。Genkit内部会自动管理工具信息,但如果你定义的工具特别多,可以在代理层面把不相关的工具从tools数组里摘掉。工具越少,模型每次调用前做选择的开销越小,误调用率也越低。
4.3 工具调用失败,不能让代理硬编答案
第三类典型问题是工具执行时报错,代理怎么办。我之前踩过一个很具体的坑:后端订单接口临时不可用,工具内部抛出了error。结果本地模型在拿到错误信息后,没有老老实实说“系统出错了”,反而很“贴心”地编造了一个订单状态来安抚用户。这在客服场景里是绝对不能接受的,用户看到的是假物流信息,比不回复更糟。
排查时我看了Genkit UI显示的工具运行日志,定位到问题根源:工具的错误信息返回给模型时,模型把“工具返回的内容”当成了“事实”。修复点有两个。第一,在工具内部把异常明确包装成带错误标记的结构:
async ({ orderId }) => { try { const result = await fetchOrder(orderId); return result; } catch (e) { return { error: true, message: '订单系统暂时不可用,请稍后再试。', }; } }第二,在instructions里补一条强制规则:“如果工具返回了 error 字段,你必须用'系统暂时无法查询,请稍后再试'来回复用户,绝对禁止生成订单状态。”这一条加上之后,代理在异常情况下的行为才终于可控。
这里我想多说一句,多回合代理调试到后期,你调的不是模型的文采,而是它的“边界意识”——什么情况下该调工具、什么情况下该复述工具结果、什么情况下该承认不知道。这些边界最好全部下沉到instructions和工具描述里,而不是散落在业务代码里。因为模型在每一轮都看得到这些指令,而业务代码只有在工具运行那一刻才执行。
5. 常见问题与排查实录
5.1 代理“忘记”了之前的话
症状:第2轮还能引用第1轮的订单号,到第5轮之后开始装失忆。
排查步骤:先去Genkit UI里看最新一次调用的请求体,确认history是否真的带上了前几轮消息。如果你发现UI里请求体只有当前这一条用户消息,那问题多半出在代码——你没有把上一轮返回的history回传,而是覆盖掉了。如果是本地模型,还要检查是否超出了上下文窗口,超了也会出现“虽有历史但就是看不到”的情况。
兜底方案就是我前面说的业务记忆摘要。把订单号、地址、用户ID这些关键信息在每轮更新后抽出来存在context里,不依赖对话历史本身。
5.2 本地模型工具调用格式混乱
症状:模型不调工具直接回复,或者调了工具但参数全是空字符串、还偶尔返回非法JSON。
排查与处理:
| 可能原因 | 检查手段 | 解决办法 |
|---|---|---|
| 工具描述太模糊 | UI里查看模型是否理解工具用途 | 重写工具description,写清触发条件 |
| schema字段描述不足 | 观察生成的参数值 | 每个字段加describe,给示例格式 |
| 模型规格太小 | 连续测试10次看成功率 | 换7B以上模型,或调整提示词 |
| 没有few-shot示例 | 查看输出格式错在哪 | 在instructions里给一个调用的完整示例 |
补充一个技巧:如果你用的是Ollama,可以调节温度参数。我最后把温度从默认的0.8降到了0.4,工具调用的稳定性提升很明显——模型更倾向于走确定性路径,而不是发散创作。代价是回复的“活泼度”有所下降,但对客服代理来说,稳定优先于活泼。
5.3 多轮对话之后延迟越来越高
症状:第1轮2秒出结果,第8轮要8秒。
原因通常不在网络,而在请求体的token量,每轮都把全部历史重复发送,本地模型的生成时间与输入token量近似成正比。另外,Ollama在显存不足时会退化为CPU推理,一轮要十几秒,那基本没法用。
处理思路:
- 限制历史轮数,只保留最近N轮,这是最立竿见影的。
- 把不重要的工具描述从tools里移除,减少每轮用来“选择工具”的开销。
- 如果条件允许,给本地模型分配更大的显存或使用量化版本模型。
- 用流式输出(streaming)让用户感觉响应变快,虽然总时长一样,但体验好很多。
5.4 工具报错被模型当成正常回答
这个前面已经详细讲过了,再补一个额外的注意点:工具函数内部不要吞异常。如果你的工具把异常catch住后返回“查询成功但是空结果”,模型会以为自己查到了真实数据。正确的做法是让工具返回结构化的error标记,并在代理指令里明确说“看到error标记就向用户致歉并说明系统暂时不可用”。宁可让用户知道系统出错了,也不能让模型生成一个虚假的答案。这条是客服场景的红线,也是我踩了几次坑之后总结出的最硬的一条经验。
6. 这套方案的边界和后续扩展
6.1 能用在哪些场景
按我这段时间的实际体会,Genkit的代理API + 本地模型的组合,最适合的场景有三类。
第一类是私域知识库问答和内部客服。企业内部的数据不能出内网,用本地模型跑代理,既能调内部系统和数据库,又能保证数据不出园区。第二类是个人助理类工具,比如日程管理、邮件草稿、信息查询,这类场景对话轮次多、重复信息多,多回合代理的“记住上下文”能力非常有用。第三类是自动化工作流的前端入口,代理相当于一个“对话式调度器”,通过工具把后端的表单提交、任务触发、状态查询都包一层,用户不用记命令,用自然语言就能操作。
不适合的场景也要说清楚:如果你需要的是高度精准的数学推理、长文档深度分析、或者对工具调用成功率要求在99%以上,那么中小体量本地模型目前还撑不住,建议优先接入云端大模型。本地模型更适合做“执行者”,而不是“终极大脑”。
6.2 后续扩展方向
做完这个项目后,我自己的扩展计划里有三件事。
第一件是多代理协作。Genkit允许在一个flow里嵌套调用多个代理,我现在是“一个客服代理包打天下”,后续准备拆成“订单代理”“售后代理”“促销代理”,由调度代理根据用户意图分发给具体代理。这样每个代理的指令可以更聚焦,工具列表更短,模型的选择压力也更小。
第二件是长期记忆。现在业务记忆还是我自己在代码里手工抽字段,比较笨。后续可以通过给代理加一个“记忆工具”,让它自己找地方读写用户画像和历史偏好,这样代理就不再依赖我预设字段,能自己慢慢积累对用户的了解。
第三件是流式响应和语音入口结合。文本的流式输出在Genkit里已经支持得很好,下一步把语音识别接进来,做一个能连续听、连续说、中间还能查数据的语音助手。多回合代理这套骨架完全可以复用,只改输入和输出层。
在给这些方案做底层支撑时,我发现一个很重要的体会:多回合AI代理的价值不在模型多聪明,而在工程上把它“圈”在合理的行为边界内。Genkit的代理API提供了一个很好的骨架,但从骨架到可用产品,中间靠的是工具描述、指令约束、历史管理和异常处理这些看起来不起眼的细节。这些细节决定了代理是“能用”还是“好用”。如果这篇文章能帮你在第一个代理的实现路上少走几圈弯路,我就觉得值了。