ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

多回合AI代理实战:用Genkit与本地模型搭建智能客服助手

多回合AI代理实战:用Genkit与本地模型搭建智能客服助手 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里每条消息都会带上roleuser、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提供了一个很好的骨架,但从骨架到可用产品,中间靠的是工具描述、指令约束、历史管理和异常处理这些看起来不起眼的细节。这些细节决定了代理是“能用”还是“好用”。如果这篇文章能帮你在第一个代理的实现路上少走几圈弯路,我就觉得值了。
返回列表