ARTICLE DETAIL

资讯详情

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

Genkit代理API实战:构建多回合AI助手与工具调用系统

Genkit代理API实战:构建多回合AI助手与工具调用系统 这几个月我一直在折腾一件事把那些只会一问一答的聊天机器人升级成真正能干活的多回合AI代理。折腾来折腾去最后把赌注压在了Google开源的Genkit上。今天这篇文章就拿Genkit的代理APIAgent API聊聊我是怎么用它搭出一个能连续对话、会自己调工具、还能跑本地模型的多回合助手。如果你已经受够了每次都要手动拼接历史消息、自己实现工具调用循环、出了问题只能靠print调试那这篇文章应该能帮你省下不少时间。先说清楚这东西解决了什么问题。一个真正的多回合AI代理不只是能记住你上句话说了啥它得能在整个对话过程中理解你在不同回合里的真实意图自主判断什么时候该查数据、什么时候该调接口、什么时候直接回答。你想想如果你让它“帮我查一下明天的天气顺便根据行程提醒我带伞”它至少得在同一个会话里完成查询、推理、提醒三次决策并且每一步都要基于已知信息更新自己对任务的判断。手写这个逻辑不算难但要写稳、写通用、写到自己都不想去改真不是一件舒服的事。Genkit的代理API恰恰就是把“模型工具多轮记忆”打包成了一个标准结构。我不需要再自己维护一堆状态变量也不用反复调模型的generate接口然后自己判断返回值是“工具调用”还是“最终回答”。这些脏活累活框架都帮我兜住了。这篇文章就围绕这个核心展开先讲讲多回合代理的设计难点再带你初始化一个可运行的Genkit环境接着用最典型的场景把代理实现出来最后把我踩过的坑全部抖给你看。1. 先搞清楚一件事多回合AI代理到底难在哪很多人在刚接触代理Agent的时候第一反应是“这不就是给模型加个循环吗”。确实把大模型放进while循环里让它反复输出“调用哪个工具、传什么参数”然后拿到结果再次喂回去这算是最朴素的代理实现了。但真要做到多回合、多工具、连续对话稳定可控难点远不止写个循环那么简单。1.1 为什么我不再手写代理循环最早我手写过一套基于OpenAI函数调用格式的代理循环核心逻辑大概是把历史消息加工具结果一起发给模型解析返回里的tool_calls如果存在就执行工具把结果追加为tool消息继续请求直到模型不再要求调用工具。这套逻辑跑通一次没问题跑十个工具也没问题但跑到第三十个工具、多种调用顺序交错、中间又夹着用户的多轮追问时问题就全暴露了。最让我头疼的是上下文管理。每一步循环需要追加哪些消息、工具结果应该放在哪个位置、用户新消息进来之后如何和之前的工具调用序列拼接这些都是非常容易出错的细节。有一次我因为把工具结果放错了消息顺序导致模型在下一轮里反复引用旧数据整整排查了一下午。后来我发现多回合代理真正考验的不是“能不能调工具”而是“如何在多轮交互中不丢失任务状态”——这恰恰是Genkit代理API想替你解决的核心问题。另一个痛点是没有可观测性。手写循环时每一轮模型到底输出了什么、工具返回了什么、为什么模型突然决定停止调用我只能在终端里打console.log去看。但多回合代理的跳转路径非常多很多时候不是线性执行的而是“用户提问-模型思考-调用工具A-模型再思考-调用工具B-终于回答”这样交织在一起的。光靠打日志看整个流程基本等于在雾里看花。Genkit从底层就在做一套强大的追踪系统这也是我最终切换到它的重要原因。1.2 代理API和普通API的差别在哪里Genkit里其实有很多API比如generate、prompt、flow它们应对的是不同层次的开发需求。generate是单次生成适合问答、摘要这类一次性任务flow是定义好的可观测业务逻辑适合把多个步骤串起来。而代理API名字上虽然也带个“API”设计思路却完全不一样它更像一个带有自主决策能力的运行时。你可以把一次代理调用理解成一个“小型操作系统”输入是用户的会话消息输出是最终回复但中间的一切都由代理自己编排。它默认就具备三个能力一是维护多回合会话记忆不用我每次手动把历史消息拼好二是根据用户意图决定是否调用工具这个决策过程对开发者来说是一个黑盒但仍然可控三是如果调用工具失败它还能重新规划甚至换个策略执行。这跟传统“你给我参数我返回结果”的API是两种完全不同的心智模型。用生活里的话说普通API像餐厅点菜你递菜单后厨出菜。代理API更像一个管家你只告诉你“晚上想吃顿好的”他会自己决定去哪个菜市场、买什么菜、几点做、遇到缺货时怎么替换。你不用告诉管家每一步怎么做你只需要给他足够的工具菜市场、冰箱、厨房和原则口味偏好、预算范围。多回合的体验就是管家会随时回来问你“今天人多要不要加个菜”这就是代理API和普通API最大的区别。2. 动手前的准备Genkit环境与核心概念在写代码之前我建议先把环境搭好、把概念理清。Genkit目前对Node.js和Go都有很好支持我这边日常用的是Node.js环境所以全文代码都以TypeScript为例。你要是不用TypeScript直接用纯JavaScript也行Genkit的类型系统只是辅助并不强制。2.1 最小环境搭建先确认你本机装了Node.js 20以上的版本这一步很重要因为Genkit依赖较新的fetch和流式处理能力。然后随便找个空目录初始化一个npm项目mkdir genkit-multi-agent cd genkit-multi-agent npm init -y npm install genkit genkit-ai/google-ai genkit-ai/ollamagenkit是核心框架genkit-ai/google-ai用来接入Gemini系列模型而genkit-ai/ollama则是跑本地模型用的插件。如果你没有Google Cloud的API Key也没有关系本地模型这条路完全走得通。官方最省事的做法是直接跑npx genkit init它会用交互式命令帮你创建项目骨架并且自动安装你选中的模型插件。但如果你是和我一样喜欢“知道每个依赖是干嘛的”的开发者手动安装其实更清晰。这里多说一句Genkit对模型提供方的抽象做得非常干净用ollama插件和用google-ai插件在代码层面几乎不需要改任何核心逻辑。这算是框架很值得称道的一点。接下来给Genkit配置本地模型。我平时用Ollama管理本地模型装好Ollama之后执行一下ollama pull llama3.1然后写一个最基础的Genkit实例import { genkit } from genkit; import { ollama } from genkitx-ollama; import { googleAI } from genkit-ai/google-ai; const ai genkit({ plugins: [ ollama({ servers: [{ baseURL: http://localhost:11434 }] }), googleAI(), ], });genkit这个函数就是整个框架的核心入口所有模型、工具、代理都要挂到它返回的实例上。我建议在一开始就把ai实例导出来后面定义工具、代理都会用到它。从这一步开始你已经有一个能跑通本地模型的Genkit环境了。2.2 理解工具、模型、上下文三个基本件在用代理API前我先梳理三个绕不开的概念分别是工具、模型、上下文。先说工具Tool。工具的本质是一个被AI可调用的函数但它不是普通函数它需要满足两个要求第一必须有清晰的输入输出描述让模型知道什么时候该调用它、该传什么参数第二执行过程可以被追踪模型调用完工具之后Genkit会在开发界面里展示完整的工具输入输出。你可以把工具想象成“给AI用的可插拔技能包”。再说模型Model。Genkit对模型做了统一封装无论你用的是云端的Gemini、Claude还是本地的Llama、Qwen在代理API里都只是一个模型名。这个设计非常省心因为不同模型对工具调用的支持程度不一样但代码层面你只需要关心“用的是哪个插件”而不是被迫去适配各家API格式。最后说上下文Context。多回合代理中上下文不只是聊天记录它还包括每一次工具调用返回的数据、系统提示词里设定的约束、以及代理内部对用户目标的持续推断。Genkit的代理API把上下文抽象成了一个history结构它会自动将用户消息、助手回复、工具调用与结果全部保存在一个有序列表里。你要做的只是决定“哪些历史信息需要传给下一次调用”而不是小心翼翼地手工拼接JSON片段。2.3 代理API的调用模型Genkit的代理API最有意思的地方是它的入口和出口都非常简单但内部处理非常复杂。一次完整的对话交互这样发生先通过defineAgent定义一个代理代理内部有模型名、指令、工具列表和可选的内存记忆组件。然后用agent.respond来发起一次对话传入一个消息数组代理就会自动完成“分析意图-决定是否调用工具-可能多次调用工具-整合结果生成回复”的完整循环。我用的方式大致是这个样子const result await agent.respond({ messages: [ { role: user, content: [{ text: 明天早上几点开会 }] }, ], });你可能注意到这里没有手动维护上一轮的历史消息。因为在多回合场景里通常我会把上一次的result.messages整体当作下一次的输入再由代理API基于完整历史继续作答。这种设计把“多回合”这个概念从开发者的琐碎记忆工作里解放了出来也把“对话惯性”交由框架统一管理。有一点我需要提前提醒不同版本里respond这个方法的名字和参数可能会有细微差别有些早期实验版本是agent.generate。我建议你以当前官方文档为准但核心思想保持不变——你给代理输入历史消息代理返回带新增消息的结果对象你把结果继续往下传就形成了多回合闭环。3. 实操用代理API做一个能干活的多回合助手讲完理论该动手了。这一节我会用一个非常贴近生活的小例子完整展示一个多回合AI代理的构建过程。场景设定是“本地生活助手”它能帮你查日程、查天气、记提醒并且能在多轮对话里自然地汇总这些信息。3.1 场景和工具定义这个助手的典型对话流程是这样的第一回合用户问“我今天有什么安排”第二回合代理查询本地日程表返回会议信息。第三回合用户继续问“下午出门需要带伞吗”第四回合代理先查下午的天气再结合前面查到的“下午有户外活动”这个日程信息给出带伞建议。这个场景非常能体现多回合的价值第三回合的问题表面上看和第一回合没直接关系但如果没有第一回合提供的行程信息代理就无法真正理解“出门”是指什么时间段、去什么场合。所以代理必须得在连续对话中自主保留并整合之前的结论。我先来定义工具。一个是查日程的getDailySchedule一个是查天气的getWeather。为了让例子简单又能真实运行这两个工具不接外部API直接用内置的模拟数据返回。import { z } from genkit; const getDailySchedule ai.defineTool( { name: getDailySchedule, description: 获取用户在某一天的日程安排返回时间段、事项和地点, inputSchema: z.object({ date: z.string().describe(日期格式YYYY-MM-DD), }), outputSchema: z.array( z.object({ time: z.string().describe(时间段如09:00-10:00), event: z.string().describe(事项名称), location: z.string().describe(地点), }) ), }, async ({ date }) { // 模拟数据 if (date 2025-03-25) { return [ { time: 10:00-11:00, event: 项目周会, location: 会议室A }, { time: 14:00-16:00, event: 户外客户走访, location: 滨江科技园 }, ]; } return []; } );注意我在工具定义里写了description千万别小看这一段描述。模型自己是看不到工具函数内部代码的它判断“什么时候该用这个工具”完全靠description和输入输出注释。我见过很多新手把description写得特别简陋比如只写“日程”结果模型在模糊场景里根本不调用它。多花两句话把“某一天”“日程安排”“时间段”这些触发条件描述清楚能显著提升工具调用准确率。接下来是天气工具const getWeather ai.defineTool( { name: getWeather, description: 获取某一天某个城市的天气情况包括降水概率和气温, inputSchema: z.object({ date: z.string().describe(日期格式YYYY-MM-DD), city: z.string().describe(城市名如杭州), }), outputSchema: z.object({ weather: z.string().describe(天气状况如晴、多云、小雨), temp: z.string().describe(气温范围如22-28摄氏度), precip: z.number().describe(降水概率百分比), }), }, async ({ date, city }) { if (date 2025-03-25 city 杭州) { return { weather: 小雨, temp: 18-22摄氏度, precip: 75 }; } return { weather: 多云, temp: 20-26摄氏度, precip: 20 }; } );我用zod定义了输入输出的Schema。这里多说一句输出Schema不只是用来约束返回的它还会作为工具说明的一部分传给模型让模型知道拿到工具结果之后能从中提取什么关键信息。如果输出结构太复杂模型在整合信息时反而容易糊涂所以建议工具返回的结果尽量扁平化、把字段含义写清楚。3.2 定义代理主体现在核心来了用代理API把上面两个工具和模型组织起来。我这里使用了一个折中的模型策略日常对话用本地Ollama的llama3.1因为它响应快、成本低而且在工具调用方面表现已经基本可用如果遇到更复杂的推理也可以随时切换到Geminigemini-1.5-pro。代理API的好处就是替换模型只需要改一个字符串。const lifeAssistant ai.defineAgent({ name: lifeAssistant, description: 本地生活助手能够查询日程、查询天气、记录提醒。, model: ollama/llama3.1, tools: [getDailySchedule, getWeather], systemPrompt: 你是一位贴心的本地生活助手。 你可以通过工具获取用户的日程安排和天气信息。 请根据多回合对话中的上下文判断用户真正想要的结果。 如果用户提到出门、安排、计划等词优先结合日程与天气回答。 回答要用中文语气自然不要暴露工具调用细节。 , });defineAgent就是代理API的核心入口。systemPrompt是代理的行为准则它在每一轮对话里都会被注入所以应该写清楚角色定位、工具使用原则、回答风格。我喜欢把“不要暴露工具调用细节”写进去比如模型就不应该说“我调用了getWeather工具”而是直接说“我查了一下杭州周五的天气”。这个细节对用户体感影响很大。定义好代理之后我再用几个变量模拟多回合会话。在实际项目中可以通过数据库、Redis或者简单的内存Map来保存每一轮的messages保证再次请求同一个用户时能带上完整历史。async function runMultiTurn() { // 第一回合 const round1 await lifeAssistant.respond({ messages: [ { role: user, content: [{ text: 我今天有什么安排 }] }, ], }); console.log(助手: round1.messages.at(-1)?.content[0].text); }我不建议直接把round1打印出来就完事多回合的精髓在于第二回合怎么用第一回合的结果。继续写async function runMultiTurn() { const conversations: any[] []; // 第一回合查日程 const round1 await lifeAssistant.respond({ messages: [{ role: user, content: [{ text: 我今天有什么安排 }] }], }); conversations.push(...round1.messages); console.log(助手: round1.messages.at(-1)?.content[0].text); // 第二回合基于已知日程继续追问天气 const round2 await lifeAssistant.respond({ messages: [ ...conversations, { role: user, content: [{ text: 下午出门的话需要带伞吗 }] }, ], }); conversations.push(...round2.messages); console.log(助手: round2.messages.at(-1)?.content[0].text); }你注意观察第二回合发生了什么。我并没有直接告诉代理“下午我要去户外走访客户”但代理在内部已经回忆出了第一天日程里有“14:00-16:00 户外客户走访”然后它判断“出门”和“户外”相关于是主动调用了getWeather工具查询下午天气再结合降水概率给出“建议带伞”的结论。整个过程里工具调用发生了三次第一次查日程、第二次查天气、甚至可能第三次再把天气结果和日程匹配但这些都被代理API自动控制住了。它拿到的不是两组孤立的数据而是一条完整的决策链。3.3 多回合语义的深层价值这一段我想展开讲讲为什么我强调多回合不是简单的“历史消息堆叠”。如果你只是把历史消息一股脑塞给模型确实也能得到“看起来记得之前内容”的回复但这不等于代理真正理解了任务。真正的多回合代理必须具备跨消息的推理能力。继续拿上面例子说。用户第二回合问“下午出门需要带伞吗”这里的“下午”在字面上只给了个时间段但代理需要知道“下午出门”指的是哪个具体事件。它从第一回合的历史里找到“14:00-16:00户外客户走访”自然推理出用户说的“出门”就是这次走访。这一步如果只靠把文本拼起来模型也能碰巧做到但如果历史消息非常长、工具调用夹杂其中模型很容易在庞杂的上下文中丢失关键事实。Genkit代理API通过保持一个结构化的消息流来降低这种迷失风险它维护的每条消息都带角色和工具调用标记模型可以清晰区分“哪句话是用户说的”“哪段内容是工具返回的数据”。我在另一个项目里就遇到过这样的对比同样是“记录预约-查询预约-取消预约”三个连续操作用普通多消息堆叠的模型多轮之后经常把预约状态搞混而用代理API加工具结构化返回每一次操作都清晰记录在工具结果里模型的准确率明显提升。所以我的结论是代理的价值不只在单次调用的“思考”更在于多次调用之间的“记忆编排”。3.4 流式输出与中间状态多回合代理在实际产品里还有一个刚需就是流式输出。如果每次用户说完话都要等十几秒才看到完整回复体验会非常差。Genkit代理API对流的支持很自然通常只需要把respond换成stream方法然后通过回调把内容块逐段推给前端。const stream await lifeAssistant.stream({ messages: [ ...conversations, { role: user, content: [{ text: 那晚上有什么需要准备的 }] }, ], }); for await (const chunk of stream.response) { process.stdout.write(chunk.content[0].text ?? ); }这里想提醒一个容易踩的坑只要代理内部发生了工具调用流式输出的节奏就容易出现“停顿”或“多段输出”。原因是模型可能在中间停下来执行工具等到工具结果返回之后再继续生产文本。前端的打字机效果要在这种情况下保持连贯需要监听一个“工具执行中”的事件而不是只等文本块。通常我会在界面上显示一个小的“正在查询天气”的状态而不是让光标干等这样用户心智负担会小很多。4. 常见问题与排查技巧实录这部分全是干货中的干货。我在用Genkit代理API搭多回合系统的过程中前前后后踩了不少坑有几个属于反复出现的类型我直接整理成速查表方便你遇到类似问题的时候快速定位。问题现象根本原因解决办法第二回合后代理突然“失忆”没有把第一回合的messages传回去每次请求带上roundN.messages的完整历史工具描述明明写了却始终不调用systemPrompt和tool description触发词不明确在description里加更具体的触发场景本地模型返回一堆JSON但没执行Ollama的模型不含工具调用训练换用Qwen 2.5或Llama 3.1这类支持工具调用的模型代理重复调用同一个工具模型没意识到工具结果已经拿到检查工具输出schema是否清晰且输出不必太长流式输出时界面文案乱跳没有区分文本块和工具调用事件用事件回调区分状态而不是直接渲染所有chunk4.1 多回合上下文丢了怎么办这是出现频率最高的一个问题。很多人说“我明明把上一轮的结果当成messages传回给代理了为什么它还是像第一次见面的陌生人”其实这类问题十有八九不是代理本身的锅而是我在传递消息时把结构弄错了。Genkit的消息结构里每条消息都有一个content数组里面是{ text: ... }这样的对象。如果只把字符串塞进去或者只保存了result.text而丢了完整的messages那代理自然无法从精简单字段里恢复全部上下文。我从这段惨痛经验里养成一个习惯始终保存代理返回结果里的messages完整数组而不要只保存最终文本。这个数组才是多回合对话的“真身”里面既包含助手回复也包含工具调用记录和工具结果。只要这个数组不丢就算代理被重启我也能把整段对话历史喂回去接着聊。另外还有一种“变相丢上下文”就是会话太长超过了模型的上下文窗口。这时即使代码没问题模型也会因为超出长度而“遗忘”掉最前面的内容。解决办法一般是做摘要压缩把前面的关键事实提炼成一段“记忆摘要”连同最后几轮原始消息一起传给代理。如果你用的是本地模型这一步尤其重要因为本地小模型的窗口通常比云端大模型短。4.2 工具调用不靠谱工具调用不靠谱的表现很多该调用的时候不调用不该调用的时候乱调用或者调用了但参数明显不对。我最开始遇到的是“参数幻觉”比如工具明明只接受date和city两个字段模型却生成了第三个不存在的字段导致运行时报错。这类问题通常有三层原因。第一层是工具说明写得不够细模型对参数格式理解不准确第二层是模型能力确实弱小模型经常在复杂工具调用上翻车第三层是输出约束太松模型在自由发挥。我的处理套路是先在工具的description里把所有可能歧义的部分都写清楚再给输入字段加严格的describe注释最后如果有必要在代理的systemPrompt中再强调一遍“只能使用工具提供的字段不要额外添加凭空想象的参数”。这里特别说一句本地模型的情况。很多本地模型虽然能聊天但工具调用能力是“伪装”出来的——它有时会把工具调用格式写错比如把JSON输出成Markdown代码块或者漏掉结束花括号。Genkit在解析失败时通常会抛出异常这时我通常会降级处理一是调整温度参数把temperature降到0.2以下让模型输出更稳定二是换一个工具调用更可靠的模型。我的经验是Ollama上的qwen2.5:7b-instruct在工具调用方面比一些更老的Llama模型要可靠得多。4.3 本地模型工具调用能力堪忧既然提到本地模型我多说几句。很多人听说“ai代理助手加本地模型”这个组合会下意识觉得完全可行但裁过跟头的都知道本地模型的工具调用表现差距非常大。决定代理能不能用主要看三点模型有没有做过工具调用专门训练上下文窗口够不够长以及数值量化对指令遵从的影响大不大。如果模型本身不擅长工具调用就会出现一个最让人崩溃的现象代理在前几轮挺好一旦连续调用两三个工具模型就开始“自说自话”不再返回任何有效的工具结果。我的建议是本地模型做主代理的话优先选qwen2.5:7b-instruct-xl或者在工具类评测中名次靠前的模型。其次一定要在systemPrompt里给出工具使用示例few-shot对于小模型的理解帮助特别明显。我甚至会把“需要调用工具时只输出JSON不要输出任何解释文字”这种强约束写进提示词虽然看起来像在“教训”模型但对稳定性帮助很大。还有一个很务实的技巧本地模型负责“初筛”在拿不准要不要调用工具时宁可多查一次也不能漏查。你可以在systemPrompt里写“如果用户意图与日程、天气相关请务必调用对应工具不要仅凭记忆回答”。因为本地模型如果省掉了工具调用它就会开始胡编天气和日程这比工具调用失败更可怕。宁可多一次工具调用延迟也不能编造数据。4.4 调试利器Genkit Developer UI最后一节送给所有遇到“玄学问题”的人不要再用console.log死磕了Genkit自带的Developer UI才是真正的侦察兵。在项目根目录执行npx genkit start它会在本地开一个开发调试面板里面能看到每一次代理调用的完整trace。说实话我第一次用代理API时工具调用“不触发”“触发两次”“参数错乱”这三个问题都是直接在trace列表里定位到的。trace会真实还原每一步模型的输入消息、模型输出的tool call结构、工具的入参和出参、最终回复文本。你一眼就能看到代理在哪一步“想偏了”是在决定工具调用时选错了工具还是在整合工具结果时理解错了数据。建议每一次改完提示词、工具描述之后都去Developer UI里重新跑一轮测试。多回合代理最迷人的地方也在这里——它不是一个你写完就完事的函数而是一个会根据上下文自主决策的小系统。开发者界面里看它一步步思考感觉就像在观察一个刚入职的实习生干活你能看到他的思考过程也能精准指出他在哪一步跑偏了。我个人在实际操作中的体会是代理API真正带给我的便利不是省掉了几行代码而是把“多回合AI代理”从一门手艺变成了一套相对标准的工程流程。过去我要花半天功夫搭的循环加状态管理现在变成了定义工具、定义提示词、接好历史消息三件事。但这不意味着你可以当甩手掌柜。模型选型、工具描述质量、上下文维护策略这些才是一个代理实际好用不好用的关键。我前前后后换了三版提示词才让本地模型在工具调用上达到“可放心交给用户”的程度。最后再分享一个小技巧你的第一个多回合代理不要贪大不要一上来就接十几个工具。先拿两个最核心的工具把闭环跑通让代理在多轮对话里能连续调用两次工具不歇菜然后再慢慢往上加技能。工具多了之后模型容易在“选哪个工具”这件事上犯糊涂所以每加一个工具都要回去把已有工具的description稍微润色一下。代理开发就是一个反复打磨的过程Genkit能把你的调试成本压下来但真正的优化空间还是在你的提示词和工具设计里。
返回列表