news 2026/9/30 5:56:30

Genkit代理API实战:构建多回合AI代理的完整指南

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Genkit代理API实战:构建多回合AI代理的完整指南

最近在做一个内部客服助手,需要支持多轮对话、查询订单状态、判断售后策略,还要能随时切回本地模型离线跑。折腾了一段时间后,我决定用Genkit来做这个多回合AI代理,整体体验比我之前裸调模型API要顺得多。这篇文章就围绕“Genkit的代理API”展开,把多回合AI代理的设计思路、核心机制、完整实操代码以及我踩过的坑都整理出来,适合正在做AI Agent相关项目、想从单轮问答升级到多轮协作的开发者参考。

1. 先搞清楚:Genkit的代理API到底解决什么问题

1.1 多回合AI代理的痛点和需求拆解

所谓的多回合AI代理,就是AI不只回答用户当前这一句话,而是能记住前面聊过什么,能根据上下文追问,能主动调用外部工具查数据,然后在“对话-推理-行动-再对话”之间来回切换,直到把问题解决。比如用户问“我上周买的东西到哪了?”,代理需要先知道用户是谁、是哪一笔订单,然后查物流系统,如果物流显示异常,还要查询售后政策并给出方案。这个过程中,每一步都有可能产生新问题、需要再次查询,模型必须带着前面的所有信息继续决策。

直接裸调大模型API做这种事,麻烦是一堆的。你得自己把历史消息数组传来传去,自己写工具调用循环,自己处理模型返回的tool_call结构,自己做会话状态持久化,还得应付不同模型提供商差异巨大的请求格式。这些事单做都不难,但堆在一起,代码量很快就失控了。尤其是当你要让代理在云端模型和本地模型之间切换时,接口差异会让人崩溃。

所以,项目里我需要的不是一个会聊天的接口,而是一个能承载“会话状态、工具调用、多回合循环”的代理框架。Genkit正好把这几块都补齐了,它能让我把注意力放在业务逻辑上,而不是反复造轮子。

1.2 为什么选择Genkit而不是裸调模型

Genkit是Google开源的AI应用开发框架,定位非常明确:让开发者用统一的方式调用模型、定义工具、编排流程,并且内置了可观测性和开发者工具。它不绑定某一家模型厂商,Google的Gemini、Anthropic、本地Ollama、OpenAI兼容接口等都能通过插件接入。这一点对我特别重要,因为我需要线上跑云端模型,调试时或数据敏感时切换本地模型。

我做一个简单对比来看不同方案的差异:

关注点裸调模型API自己封装Agent轮询Genkit
多回合历史管理完全手动拼数组自己实现内置session机制,自动维护
工具定义与校验按各家格式写自己写parser使用Zod schema定义,自动校验
工具调用循环需要手动while自己实现有request-response循环封装
模型切换改大量代码改调用层换插件即可
可观测性无自己打日志自带开发者UI和trace

这是很实际的选择逻辑:如果你的代理只有一两个回合、不需要复杂工具,那裸调完全没问题。但一旦你准备做真正的业务型AI代理,Genkit这类框架的收益是非常明显的。它解决的核心问题是“代理的工程化”,而不只是“模型调用”。

Genkit的代理API也不是凭空造出来的概念。它把整个代理跑起来需要的东西都标准化了:模型、工具、上下文、循环。代理可以理解成一个“有手有脚会记忆的对话机器人”,Genkit提供的就是给它套上手脚、接上记忆的插座。

2. 环境准备与工具选型

2.1 项目初始化与依赖安装

我使用的是Node.js环境,Genkit对TypeScript支持很完善。如果你的项目还没初始化,先做基础准备:

mkdir genkit-agent-demo cd genkit-agent-demo npm init -y

然后安装核心依赖:

npm install @genkit-ai/core @genkit-ai/flow npm install @genkit-ai/google # 云端模型插件 npm install genkitx-ollama # 本地模型插件 npm install zod # 结构化校验 npm install -D @genkit-ai/cli # 开发者工具

安装完成后,用npx genkit init可以直接生成项目模板,但我倾向于手动搭建,结构更清晰。

需要注意的点:Genkit要求Node.js 18以上,建议用20版本。如果你之前的项目有其他AI依赖,版本冲突是常有的事,最好在新目录里做依赖隔离。我在实践中还发现,@genkit-ai/flow和@genkit-ai/core的版本必须匹配,否则运行时会报奇怪的插件错误。所以第一次安装时,建议都用默认的latest版本,并且生成好package-lock.json,后续升级再统一规划。

2.2 模型接入:云端API与本地模型两条路

Genkit的模型接入是通过插件实现的。我想要的是同一套代理代码,既能调用云端模型,也能切换到本地模型,所以最理想的做法是把模型定义放在一个独立模块中,后续随时替换。

云端模型我用的是Google的Gemini系列,配置很简单:

import { googleAI } from '@genkit-ai/google'; import { genkit } from '@genkit-ai/core'; const ai = genkit({ plugins: [ googleAI({ apiKey: process.env.GOOGLE_API_KEY }) ], model: googleAI.models.geminiPro(), });

环境变量GOOGLE_API_KEY放到.env里,Genkit会自动加载。有一点要特别提醒,密钥千万别写进代码里,尤其不要提交到Git仓库。我习惯在项目根目录建一个.env.example文件,把需要的环境变量列出来,这样团队成员clone下来就知道要配什么。

本地模型方面,我使用Ollama。Ollama是一个本地运行大模型的工具,一条命令就能拉起一个模型服务,特别适合离线场景:

ollama pull qwen2.5:7b ollama serve

然后在Genkit里接入:

import { ollama } from 'genkitx-ollama'; const ai = genkit({ plugins: [ ollama({ servers: [{ baseURL: 'http://localhost:11434' }] }) ], model: ollama.models.qwen2_5(), });

这里要注意,不同的ollama模型命名可能不同,使用前最好先用ollama list查看本地已经拉取的模型名称,再根据genkitx-ollama支持的方式去引用。我当时用llama3.1时模型名称是llama3.1,换成qwen2.5时就要变成qwen2.5或者qwen2_5,具体看插件版本。如果引用不存在的模型名,调用时会直接报错。

云端模型和本地模型两者各有优势。云端的推理能力强、工具调用更稳定,适合生产;本地模型无网络依赖、数据不出本地,适合做开发和敏感性较高的场景。在Genkit里切换它们,其实就是换插件配置,代理逻辑完全不用改。

3. 多回合代理的核心机制拆解

3.1 会话状态与会话历史管理

多回合AI代理的核心是“记忆”。想象一下,你去窗口办事,如果接待员不记得你上一句话说了什么,你每次都得从头解释,那效率得多差。多回合代理的记忆就是会话状态,它至少包含两部分:对话历史消息列表,以及当前任务的中间状态(比如已查到的订单号、已选择的处理方案)。

Genkit处理会话的方式并不是强制规定,而是提供了session相关的抽象,让你可以自己管理上下文。我实际用的方案是把历史消息按会话ID存到Redis里,每次请求到达时,从Redis取出历史消息,追加用户新消息,组成一个完整的消息数组,再交给模型。

这里有一个特别重要的技术细节:消息数组中需要有不同类型的消息。通常包括system(系统提示)、user(用户输入)、model(模型回复)、tool(工具返回结果)。模型会基于完整数组理解上下文。如果你只是把用户的聊天文本拼在一起传进去,模型很难分清哪些是历史、哪些是当前请求,回答就会混乱。

我也会设置上限,比如最多保留最近30条消息。因为对话越长,token消耗越大,响应越慢。更长的历史可以通过摘要来压缩,但这会引入信息丢失的问题。具体策略我下面踩坑环节会详细说。

3.2 工具调用与代理循环

工具调用是代理区别于普通聊天机器人的关键。没有工具的模型只会“动嘴”,有了工具它才能“动手”。我给你打个比方:用户问“帮我查一下尾号8832的订单到哪了”,模型本身不知道订单数据,它应该生成“有一个工具可以查订单物流,参数是订单号”,而不是瞎编一个物流地址。然后代理框架把这个工具请求执行掉,拿到真实结果,再把这个结果作为上下文交回给模型,让模型基于真实数据继续回答。

这个过程就是著名的代理循环。伪代码大致是:

  1. 把系统提示、历史消息、当前用户输入交给模型;
  2. 模型返回结果,可能是文本回复,也可能是工具调用请求;
  3. 如果是工具调用请求,代理执行对应工具;
  4. 把工具执行结果附加到消息序列中,再次发给模型;
  5. 重复步骤2-4,直到模型返回纯文本回复,或达到最大循环次数。

这里最容易被忽略的是:工具执行结果本身也要成为对话历史的一部分。很多初学者把工具执行结果直接丢掉,下次模型就不知道刚才查到了什么,自然就无法给出连贯答案。正确的做法是把工具结果作为一个消息追加到对话上下文中,让代理时刻“记得”自己已经做过哪些查询。

3.3 Genkit中的代理流程配置

Genkit里有一个核心概念叫Flow,它把输入、输出、业务步骤、错误处理统一封装起来。你可以把Flow理解为代理的“外包装”,让代理可以被HTTP调用、被命令行调用、也可以嵌入到任意Node.js服务中。配合Genkit的defineFlow,我再结合自定义的代理循环,就能得到一个生产可用的代理服务。

在Genkit的生态里,代理API并不只是一段变量名,而是一个相对完整的抽象层:它帮你把模型调用、工具注册、上下文组装这几件事绑定到同一个流程里。虽然我们可以手写循环,但Genkit也提供了很多工具方法,比如针对消息的辅助函数、模型生成的封装,这让代码更简洁也更不容易出错。我下面的实操部分会完整展示这个流程。

4. 实操:用Genkit的代理API构建一个多回合订单助手

4.1 定义业务工具

我先拿一个具体的案例来做:订单助手。这个代理能查订单状态、能查退货政策,并且能基于多次查询的结果给出综合性答复。第一步是定义工具。

工具本质上是一个函数,包含名称、描述、参数Schema和业务实现。描述非常重要,因为模型就是靠描述来决定“什么时候该调用这个工具”的。描述写得越清楚,工具调用准确率越高。

我用Zod来定义参数结构:

import { z } from 'zod'; import { defineTool } from '@genkit-ai/core'; const getOrderStatusTool = defineTool({ name: 'getOrderStatus', description: '根据订单号查询订单的物流状态和当前节点', inputSchema: z.object({ orderId: z.string().describe('订单号,形如20240501XXX'), }), outputSchema: z.object({ status: z.string(), location: z.string(), estimatedDays: z.number(), }), }, async ({ orderId }) => { // 这里替换为真实业务系统的查询逻辑 return { status: '运输中', location: '上海转运中心', estimatedDays: 2, }; });

退货政策工具也很简单:

const getRefundPolicyTool = defineTool({ name: 'getRefundPolicy', description: '查询商品退货政策,了解七天无理由退货规则', inputSchema: z.object({ category: z.string().describe('商品类别,如数码、服装、食品'), }), outputSchema: z.object({ policy: z.string(), windowDays: z.number(), note: z.string(), }), }, async ({ category }) => { // 模拟返回政策数据 const policies: Record<string, any> = { '数码': { policy: '支持七天无理由退货', windowDays: 7, note: '需保证包装完好' }, '服装': { policy: '支持七天无理由退货', windowDays: 7, note: '吊牌未拆' }, '食品': { policy: '不支持无理由退货', windowDays: 0, note: '食品安全法规定' }, }; return policies[category] || policies['数码']; });

这里有几个实战要点。第一,inputSchema里每个字段的describe文字不要省略,它是模型理解参数含义的来源。第二,工具实现要尽量做异常处理,比如订单号不存在时抛异常或返回错误状态,否则模型可能把异常输出当成正常结果。第三,工具返回的数据结构要简单,嵌套太深会让模型难以消化。

4.2 编写代理主体与多回合循环

有了工具之后,接下来是组装代理。我采用的方式是:先用Genkit初始化一个实例,把模型、工具都注册进去,然后定义一个chatFlowFlow,在这个Flow内部实现多回合循环。

先看初始化代码:

import { genkit, defineTool } from '@genkit-ai/core'; import { defineFlow } from '@genkit-ai/flow'; import { googleAI } from '@genkit-ai/google'; const ai = genkit({ plugins: [googleAI({ apiKey: process.env.GOOGLE_API_KEY })], model: googleAI.models.geminiPro(), tools: [getOrderStatusTool, getRefundPolicyTool], });

接下来是核心的循环。我定义了一个chatFlow,它的输入是会话ID和用户消息,输出是代理最终回复:

import { z } from 'zod'; export const chatFlow = defineFlow({ name: 'chatFlow', inputSchema: z.object({ sessionId: z.string(), message: z.string(), }), outputSchema: z.object({ reply: z.string(), data: z.any().optional(), }), }, async (input) => { const history = await getHistory(input.sessionId); history.push({ role: 'user', text: input.message }); const maxIterations = 5; let reply = ''; for (let i = 0; i < maxIterations; i++) { const response = await ai.generate({ messages: history, tools: [getOrderStatusTool, getRefundPolicyTool], config: { temperature: 0.3 }, }); const text = response.text(); const toolRequests = response.toolRequests; if (!toolRequests || toolRequests.length === 0) { reply = text; break; } for (const request of toolRequests) { history.push({ role: 'model', toolCalls: [{ id: request.id, name: request.name, args: JSON.parse(request.input), }], }); } // 执行工具并同步结果 const toolResponses = await ai.runTools(toolRequests); for (let j = 0; j < toolResponses.length; j++) { history.push({ role: 'tool', name: toolResponses[j].name, result: toolResponses[j].output, }); } } if (!reply) { reply = '抱歉,我暂时无法处理这个请求,请稍后重试。'; } await saveHistory(input.sessionId, [...history, { role: 'model', text: reply }]); return { reply }; });

这段代码虽然简化了一些,但完整展示了代理循环的骨架。有几个细节我想重点解释。

ai.generate中的messages是整个会话历史,它来自之前存好的历史加上刚收到的用户消息。这样代理才能记住前面聊过什么。toolRequests是模型返回的工具调用请求数组,如果为空就代表模型已经可以直接回答了。

执行工具这一步,我用了Genkit的ai.runTools方法,它会根据模型返回的请求自动找到注册过的方法并执行,不需要自己写switch-case分发。如果工具执行过程中出错,runTools可能会抛出异常,因此实际项目中需要在循环里包裹try-catch,避免代理流程直接挂掉。

另外要注意的是,每个工具调用完成后必须把工具结果作为role: 'tool'的消息追加进历史。这一步如果漏了,模型在下一次循环时就会“失忆”。我最初写的时候漏掉过,结果代理总是重复调用同一个工具,后来调试了很久才找到原因。

4.3 接入本地模型让代理离线可跑

云端模型稳定且聪明,但很多场景需要本地化运行,比如内网环境、敏感数据、离线演示。Genkit接入Ollama可以说是一行配置的事,但真正跑通代理循环还有一些额外工作。

首先,要切换到本地模型,只需把Genkit实例的模型参数改成Ollama即可:

import { ollama } from 'genkitx-ollama'; const ai = genkit({ plugins: [ ollama({ servers: [{ baseURL: 'http://localhost:11434' }] }) ], model: ollama.models.qwen2_5(), tools: [getOrderStatusTool, getRefundPolicyTool], });

然后重新启动服务,整个chatFlow不用改一行,代理就能用本地模型跑了。

但这里有个大坑:本地小模型的工具调用能力远不如云端大模型。Gemini、GPT这些模型经过专门训练,能很自然地生成结构化工具调用。而7B、13B级别的本地模型,经常不按套路出牌,可能直接返回一段描述性文字而不是结构化调用请求。Genkit对工具调用做了兼容处理,但模型本身不支持的话,框架也没办法凭空变出来。

我在实操中有一个很管用的折中方案:不是强制本地模型输出标准tool_call结构,而是让它学会使用一个“describe_action”文本协议。具体做法是把工具描述写得很详细,让模型输出类似ACTION: getOrderStatus, orderId=20240501XXX的字符串,然后我在代理循环层自己解析这个字符串并执行对应函数。这个方法不如原生工具调用优雅,但兼容性提高了很多。

另一个更省事的办法是选择对工具调用支持较好的本地模型。Qwen系列在工具调用上的表现相对不错,模型参数在7B以上时效果才可用。如果你只是做开发调试,那完全可以用本地模型;但生产级的多回合工具调用,我个人建议优先用云端模型,本地模型用于回退或数据敏感场景。

5. 踩坑记录与排查技巧

5.1 多回合上下文丢失问题

这个坑我印象最深。一开始我的代理只能正确回答第一轮指令,从第二轮开始就会“崩溃”,表现为忘记用户之前提供的订单号,或者重复询问已经回答过的信息。排查下来发现是历史消息没有正确传递:我只把用户新消息发送给模型,之前的对话没取出来;或者存历史时忘了把模型上一轮的回复也存进去,导致历史消息少了一环。

解决方案也很简单:严格按照system -> user -> model -> user -> model的顺序来存。如果涉及工具,则插入model(tool_call) -> tool(result)。我把完整的消息数组统一存到Redis里,每次调用前原样取出,跑完后再把新消息追加回去。另外要注意,ai.generate的消息对象里,字段名要符合Genkit的约定,不要自己发明字段名。比如工具消息就必须用role: 'tool',不能写成role: 'function',否则框架无法识别。

还有一点很隐蔽:当你用Redis存历史时,要记得存的是序列化后的完整对象,而不是只存文本。因为消息里除了文本,还有工具调用、工具结果等结构化字段。如果丢弃结构,之后再传给模型,模型也无法理解上下文。

5.2 工具调用死循环与异常处理

代理循环最怕的就是“模型反复调用同一个工具而不给最终答案”。比如查订单状态的工具被调用了5次,每次参数都相同,模型还是继续请求。这种情况通常是因为工具结果没有正确反馈给模型,模型发现自己“看到的”信息还是不全,就不断尝试。

我在代码里做了一个硬性保护:设置最大循环次数,默认5次。超过次数直接返回兜底文案。同时在每次循环中,会检查工具请求是不是和上一轮的请求完全一样,如果一样,就中断循环并让模型基于已有信息回答。下面是精简版判断逻辑:

const lastKey = JSON.stringify(toolRequests); if (lastKey === previousKey) { reply = '根据已有信息,我建议您稍后再查,或联系人工客服。'; break; } previousKey = lastKey;

另外,工具内部可能抛异常,比如第三方接口超时。这种异常不能让整个代理挂掉,而是要把错误信息作为工具结果返回给模型。例如工具返回{ error: '订单号不存在' },模型看到后就会向用户解释“没有查到该订单”,而不是报错。我通常会在所有工具函数的最外层加try-catch,把异常转成可解析的结果对象。

5.3 本地模型响应格式不兼容

本地模型走Genkit时,最容易遇到的是响应格式不符合预期。具体表现是:模型生成的文本不是JSON,或者没有tool_call结构,Genkit解析时抛异常。这时候先不要怀疑框架,要检查模型本身的能力。

我分享一个排查顺序:

  1. 先用curl直接对着Ollama发一个带tools参数的请求,看模型能不能返回tool_calls字段。如果连原始接口都拿不到,说明模型不支持原生工具调用。
  2. 如果原始输出有tool_calls,但Genkit解析失败,多半是消息结构问题,检查toolRequests对象的input是不是JSON字符串,可能需要对JSON.parse做容错。
  3. 如果模型完全不会调用工具,那就只能采用文本协议,让模型以特定字符串格式输出动作指令,自己解析执行。

本地模型温度参数也要调整。工具调用需要确定性较高的输出,温度调到0.2以下明显更稳定。温度高了,模型可能每轮输出的格式都不一样,循环很容易失控。

5.4 性能与成本调优

多回合代理对token的消耗比普通聊天大得多。因为每轮循环都要把历史消息重复发送一遍,工具调用还会额外增加请求次数。按我的项目统计,一个完整的订单查询咨询大约消耗3000~5000 tokens,如果对话超过10轮,成本会成倍上涨。

几个实用的优化方向:

  • 对历史消息做滑动窗口,只保留最近N条消息。对更早的对话做一次摘要,并把摘要压缩成一条系统消息。
  • 减少不必要的工具调用。只有用户确实提到订单、物流、退货等关键词时,才允许模型调用工具。这个可以用系统提示来控制。
  • 使用流式输出提升体验,但要注意流式输出的工具调用处理相对复杂,建议先跑通非流式再上流式。
  • 如果使用云端模型,考虑开启模型缓存。Gemini有上下文缓存,能把反复发送的历史消息缓存起来,大幅降低成本。

我用下表总结一下我的调优前后对比:

优化操作平均单次会话token响应延迟成本影响
全量历史约6000约2.5秒高
滑动窗口30条约3500约1.8秒中
窗口+摘要压缩约2500约1.5秒低

实测下来,滑动窗口加摘要压缩是最划算的组合,记忆效果基本不受影响,成本和响应速度都有明显改善。

6. 补充一些使用后的个人心得

我用了Genkit一个月后,最大的感受是:它把“代理”这件事从手工活变成了织布活。你不需要再自己写身份验证、请求签名、消息转换、工具分发这些基础代码,而是可以专注在业务规则、工具设计、Prompt优化这些真正影响体验的地方。多回合代理最难的从来不是“接好大模型”,而是“让模型在正确的时间调用正确的工具,并且记住自己已经做过的操作”。Genkit的Flow、工具注册、消息管理帮我把这套机制固定了下来。

如果你想快速验证思路,我建议不要一开始就上Redis、上云端模型,先用本地模型加一个简单的内存存储跑通循环,再逐步替换成生产组件。多回合代理的调试很依赖可观测性,Genkit自带的开发者UI能清楚看到每一次模型请求、每一轮工具调用,有了它再复杂的循环问题也能快速定位。

最后再分享一个小细节:工具描述里的每一个字都值得反复打磨。模型是否知道“什么时候该调用退货政策”,很大程度上取决于你描述里的触发条件写得是否准确。我在迭代中发现,把业务示例直接写进工具描述可以明显提高调用准确率,比如“当用户提到退货、退款、七天无理由时,调用getRefundPolicy”,这比只写“查询退货政策”效果好得多。如果项目可以持续积累,建议用真实对话日志去回放测试,不断优化描述,这比一味换更大模型更划算。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/30 5:55:26

《码上面试》Agent开发实战:手写ReAct构建AI面试官全解析

我最近在做一个叫《码上面试》的小项目&#xff0c;核心思路是让 AI Agent 扮演面试官和陪练&#xff0c;帮程序员准备技术面试。整个项目从零起步&#xff0c;踩了不少坑&#xff0c;也积累了不少关于 Agent 开发的一手经验。我打算用几篇博文把它记录下来&#xff0c;这篇是第…

作者头像 李华
网站建设 2026/9/30 5:53:21

从二进制到补码、浮点与文件签名:底层数据表示全解析

上次带一个刚转行做后端的同学看串口抓包日志&#xff0c;他盯着满屏的 0 和 1 冒出一句&#xff1a;计算机为什么非得用二进制&#xff1f;十进制不是更贴近人的习惯吗&#xff1f;这个问题听着像入门第一课的课后题&#xff0c;可它牵出来的东西一点都不浅——内存怎么存数、…

作者头像 李华
网站建设 2026/9/30 5:52:35

Saddle实战:可视化任务流平台如何破解AI/MLOps落地难题

1. AI/MLOps这块硬骨头&#xff0c;到底难啃在哪先说一个我观察到的现象&#xff1a;很多团队在模型训练阶段一马平川&#xff0c;一到上线就进入"鬼打墙"状态。训练好的模型孤零零躺在模型仓库里&#xff0c;算法工程师说不清"我这段预处理逻辑线上跑没跑"…

作者头像 李华
网站建设 2026/9/30 5:52:34

Linux ln命令详解:硬链接、符号链接与生产实践

1. 从一次"删了源文件&#xff0c;链接就废了"的线上事故说起几年前我接手过一个发布流程的重构&#xff0c;前任留下的部署脚本里有一堆软链接&#xff1a;/opt/app/current指向/opt/app/releases/20230512这类目录&#xff0c;灰度切流全靠改这个链接。某次清理磁盘…

作者头像 李华