☰
Genkit实战:构建多回合AI代理的上下文记忆与工具调用
2026/10/2 3:59:40 网站建设 项目流程

最近在做一个售后客服机器人,最头疼的不是模型答得不好,而是代理在多轮对话里经常“失忆”:上一步查到的订单状态,下一步就忘了;更麻烦的是,模型动不动就把工具调用玩成死循环。试过自己撸循环,也试过LangChain里那套,最后切到Google开源的Genkit,用它的代理API把多回合AI代理的架子搭了起来,整个流程顺畅了很多。

“利用Genkit的代理API构建多回合AI代理”这个项目,说白了就是解决三件事:上下文记忆、工具调用循环、对话状态管理。这篇文章不是官方文档的复读,而是我从零跑通一个客服代理的完整记录,包括核心概念、代码、参数设置、调试技巧和踩坑总结。如果你第一次接触Agent开发,或者手里有单轮对话想升级成真正能干活的代理,按这篇的思路直接上手就行。

1. 项目拆解:我们要构建什么

1.1 多回合AI代理的核心难点

多回合AI代理和普通聊天机器人最大的区别在于:每一次回复都可能触发工具调用,而工具调用结果又会影响下一轮回复。这导致我们至少面临四个问题。

第一,历史消息的维护。不能只把用户最新一条消息丢给模型,必须把之前的工具返回结果、代理已经采取的决策都放进上下文里。比如用户第1轮说“查一下订单”,第2轮说“顺便退了”,模型需要知道“查一下订单”的结果,否则根本不知道该退什么。

第二,循环控制。模型说“我要调用getOrderStatus”,我们不能直接把这个输出给用户,而是要执行工具,把结果附加到消息序列,再次请求模型。这个“生成-调工具-拿到结果-再生成”的循环是代理的核心引擎,没有正确的循环控制,代理就会停在半路上。

第三,错误处理。工具执行失败时,比如订单号不存在,能不能让模型知道“刚才那个工具失败了,请你换个方式处理”,而不是直接把崩溃信息展示给用户,或者更糟——让模型编造一个不存在的订单状态。

第四,对话终点判断。模型可能连续调用几次工具,也可能直接回复用户,什么时候停止这个循环非常关键。调用太多次会浪费token,调用太少可能没完成任务。

这四条其实是所有多回合代理的公共难题。Genkit把这条循环做成了标准API,很多底层细节不需要自己处理,但我依然建议你理解这个循环的每一步,因为后面所有的参数调优和问题排查都建立在这个理解之上。

1.2 为什么选Genkit而不是自己硬撸

当时我对比了三条路:自己写循环、用LangChain的AgentExecutor、用Genkit。

自己写循环最灵活,但工程量大。要处理消息格式统一、token截断、并发安全、重试机制、模型返回的tool_call格式校验……这些杂活会占据大量时间,真正留给“业务逻辑”的时间反而少了。更麻烦的是,一旦换了模型厂商,整个循环又得跟着适配一遍。

LangChain那套我也试过,AgentExecutor的概念挺全,但版本迭代太快,API变来变去。网上很多示例跑不通,而且工具调用的中间状态被封装得太死,调试时不太直观。

Genkit给我最大的感受是“中间过程全透明”。它提供了trace机制,代理每一步推理、每一次工具调用、每个模型输出的原始返回都能看到。这对多回合代理来说极其重要,因为这类系统出问题的时候,定位问题往往比修复问题更耗时。

另外一个很实际的原因是Genkit原生支持TypeScript。工具定义用Zod写Schema,类型从输入到输出都是通的,模型返回参数如果不符合Schema会在类型层就暴露出来,而不是等到运行时报错。这种安全感自己手搓很难获得。

2. 环境准备与项目初始化

2.1 安装Genkit CLI和依赖

我用的Node版本是22,Genkit要求Node 20以上。你可以先用下面的命令初始化项目:

mkdir multi-turn-agent && cd multi-turn-agent npm init -y npm install @genkit-ai/core @genkit-ai/ai @genkit-ai/googleai npm install -D @genkit-ai/cli

这里@genkit-ai/core是核心运行时,@genkit-ai/ai提供了Agent API和工具定义API,@genkit-ai/googleai是Google模型插件,我用的是Gemini 2.0 Flash。如果你要用OpenAI,装@genkit-ai/openai;要接本地模型,可以用Ollama插件。模型厂商在Genkit里是插件式的,换起来很方便,但我不建议一开始就做多模型适配,先跑通一条链路更重要。

注意:Genkit的Agent API在0.9版本之后才比较完整。如果你用的是genkit老版本,看到的可能还是旧的chat接口,建议先升级到0.9.x再继续。

2.2 初始化项目结构

我习惯把项目拆成这样:

  • tools/:存放代理能调用的工具定义
  • agent.ts:定义代理逻辑
  • server.ts:起一个本地服务,方便通过HTTP调用代理
  • config.ts:统一存放模型名、API key、参数

这个结构不是强制要求,但建议把工具和代理分开。多回合代理的工具一多,如果全部堆在一个文件里,后期你会完全不敢改动任何一个工具的描述,因为改一个字段都可能影响模型的行为。

2.3 配置模型提供商

在config.ts里写:

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

启动前先export GOOGLE_API_KEY=xxxx。这里不推荐把API key写死在代码里,除了安全问题之外,还有一个原因:Genkit自带dotenv支持,直接在项目根目录放一个.env文件就不会被读到git里。你要是在代码里写死了key,后面部署到任何环境都得改代码,很容易因为key泄露吃大亏。

3. 用Genkit Agent API搭建代理骨架

3.1 Agent API的关键概念

Genkit把代理定义为defineAgent,核心签名是这样的:

import { defineAgent } from '@genkit-ai/ai'; defineAgent( { name: 'customerAgent', description: '售后客服代理', tools: [getOrderStatus, applyReturn], systemPrompt: '你是售后客服助手,负责查询订单和处理退货。', maxTurns: 5, temperature: 0.2, }, async (input) => { return input.text; } );

关键概念有三点。

第一,Agent拥有自己的system prompt和工具集合。这个工具集合是给模型看的“能力清单”,模型会依据你的描述来决定调哪个工具,所以工具描述写得好不好,直接决定代理智能不智能。

第二,每次触发run,Genkit内部都会维护一个AgentRun上下文。你不需要自己管理中间轮次的messages,Genkit会把模型输出、工具请求、工具结果全部记录在run上下文里,直到模型生成最终文本回复。

第三,Agent的input字段是自定义的。你可以只传text,也可以传结构化对象(比如{ text: '查订单', userId: 'abc' }),这比传统聊天API自由很多,适配复杂业务时非常有用。

3.2 第一版:无工具代理的多回合对话

先做一个最简版本,确认基础链路没问题:

const basicAgent = defineAgent( { name: 'basicAgent', description: '多回合闲聊代理', systemPrompt: '你是一个乐于助人的AI助手。', }, async (input) => { return await input.text; } );

这个代理没有工具,但它天然支持多回合。调用时传入history,Genkit会把之前的所有用户消息和AI回复都放进上下文:

const response = await basicAgent.run({ input: { text: '我刚才问的天气问题,你还没回答呢' }, history: [ { role: 'user', content: [{ text: '今天天气怎么样?' }] }, { role: 'model', content: [{ text: '我还没有查询天气的工具,可以告诉我你的城市吗?' }] }, ], });

注意这里history里全是content数组,而不是直接写字符串。Genkit的消息格式比OpenAI的messages格式更结构化,里面的content可以放文本、媒体、工具请求、工具结果等多种类型,以后要扩展成多模态对话时不用改接口。

不过这里也有一个陷阱:如果你不显式传入history,代理只看当前输入,基本上每次都是“新对话”。所以真正的多回合代理,一定要在外面维护会话状态,把历史传给run方法。这个问题我在后面的进阶部分会展开讲。

3.3 消息历史与上下文管理机制

这里要理解Genkit的输入输出格式:Agent的input是一个对象,history是消息数组,每条消息有role和content,content里可以包含text、media、toolRequest等。

这个格式跟OpenAI的messages有相似之处,但有三个区别值得注意。

第一,content永远是一个数组。这意味着你可以在一条消息里同时包含文本和图片,也可以把多个文本块拼在一起,模型不会混淆。

第二,工具调用和工具结果在消息里是不同类型的对象。OpenAI早期把所有东西都塞进content字符串,让message变得很难解析。Genkit把toolRequest和toolResult作为结构化字段,程序处理起来省事很多。

第三,Genkit会自动处理token截断。当历史消息超过模型上下文窗口时,它不会直接报错,而是按策略丢弃较早的消息。但这个自动截断不一定满足你的业务需求,所以最好还是自己控制历史长度,别什么都往里塞。

重要:不要在systemPrompt里手动拼接历史消息。我之前犯过这个错,把历史藏进system prompt里,结果Genkit自动截断的时候会把你的“伪历史”当成组装指令给吞掉,代理行为变得非常诡异。历史就该放history字段,让框架去管理。

4. 让代理真正能干活的工具定义与调用

4.1 定义工具:从简单函数到带Schema的LLM工具

工具定义的核心是给模型一个清晰的“调用说明书”。用defineTool:

import { z } from 'zod'; const getOrderStatus = defineTool( { name: 'getOrderStatus', description: '根据订单号查询订单状态,返回物流信息和预计送达时间', inputSchema: z.object({ orderId: z.string().describe('订单号,格式为ORD-2025-xxxx'), }), outputSchema: z.object({ status: z.string(), courier: z.string().optional(), estimated: z.string().optional(), }), }, async ({ orderId }) => { // 这里模拟查数据库 return { status: 'shipped', courier: 'SF', estimated: '2025-03-01' }; } );

这里有几个关键点值得单拎出来说。

inputSchema一定要写清楚每个字段的含义。模型是通过自然语言描述来理解参数的,describe写“订单号,格式为ORD-2025-xxxx”,模型就知道不能乱填。如果写成“订单号”,模型很容易把用户说的“我昨天买的东西”当成参数填进去。

description要写“什么时候用这个工具”。这是模型路由的核心依据。如果你写“查询订单状态的函数”,模型在退换货场景下就不知道到底该调用它还是调用退货工具。好一点的写法是“当用户询问订单状态、物流信息、送达时间时使用,仅用于查询,不处理退货”。

工具函数内部的报错信息要吞住并返回结构化错误。不要让异常直接抛到Agent循环里,否则整个对话就中断了。更推荐的方式是返回{ error: '订单不存在' }或{ status: 'NOT_FOUND' },让模型基于这个结果决定下一步怎么回复用户。

4.2 多回合代理中的工具调用循环

当代理拿到工具后,它内部会跑这样一个循环:

  1. 把用户消息、历史消息、工具定义一起发给模型。
  2. 模型返回两种情况:直接生成文本回复,或者返回一个工具请求。
  3. 如果是工具请求,Genkit会执行你定义的工具函数,把返回值包装成toolResult消息。
  4. 把toolResult放回消息序列,再次调用模型。
  5. 重复第2步到第4步,直到模型输出文本回复,或达到maxTurns上限。

这个循环看起来简单,但真正难点在于“工具返回结果怎么描述”。如果工具返回超长JSON,模型可能读不完,或者把JSON里某个字段误解成用户输入。所以在定义outputSchema时,只返回模型做决策需要的信息,不要图省事直接return整个数据库记录。

举个例子,订单表有30个字段,但代理只需要知道“这个订单能不能退”,输出Schema里就只需要orderId、status、canReturn这几个字段。字段少,模型被误导的概率就小,token消耗也少。

4.3 设置Agent级参数:maxTurns、temperature、systemPrompt

Agent API允许在defineAgent配置里设置这些参数,直接影响多回合行为。

maxTurns:限制工具调用最大轮数,防止死循环。我一般设5,对于客服场景够用了。如果你的代理需要串联很长的工具链,可以适当放宽到8,但不要超过10,否则一个请求可能要跑几分钟,用户早就走了。

temperature:多回合决策类任务我建议用0.1到0.3。温度越低,模型越倾向于执行保守的确定性步骤,不会频繁变换工具策略。做创意类任务才需要高温度,客服代理不需要创意,它需要稳定。

systemPrompt:写清楚代理的身份、可用能力、输出规范。特别是“如果你不确定,不要编造信息”这类约束,能显著减少幻觉。

提示:客服场景里,我几乎都会在systemPrompt里写一句“工具查询不到结果时,如实告知用户,而不是编造一个订单状态”。这句话不用太多,但对行为的纠正效果非常明显。

5. 完整实战:实现一个多回合客服代理

5.1 需求与流程设计

为了演示一个完整的多回合代理,我设计了售后客服场景。用户可能查询订单、申请退货、确认退货进度。代理需要根据上下文判断该调用哪个工具,并且处理连续查询。

流程设计是这样的:

  1. 用户说话。
  2. 代理判断意图,必要时先查订单。
  3. 用户追问物流细节,代理继续查物流工具。
  4. 用户要求退货,代理调退货工具,生成退货单号。
  5. 代理汇总结果,给出最终回复。

整个过程用户只发几句话,但代理内部可能跑了三次工具调用。这就是多回合代理相比单轮聊天的价值所在。

5.2 核心代码实现

import { defineAgent, defineTool } from '@genkit-ai/ai'; import { z } from 'zod'; import { ai } from './config'; const getOrder = defineTool( { name: 'getOrder', description: '根据订单号查询订单基本信息,包括订单状态、商品名称、购买时间。当用户询问订单状态、订单详情、物流信息时使用。', inputSchema: z.object({ orderId: z.string().describe('订单号,以ORD开头,例如ORD-001'), }), outputSchema: z.object({ product: z.string(), status: z.string(), buyTime: z.string(), }), }, async ({ orderId }) => { // 模拟订单系统 const mockOrders: Record<string, any> = { 'ORD-001': { product: '无线鼠标', status: '已发货', buyTime: '2025-02-01' }, 'ORD-002': { product: '机械键盘', status: '已签收', buyTime: '2025-01-20' }, }; const order = mockOrders[orderId]; if (!order) { return { product: '未知', status: 'NOT_FOUND', buyTime: '' }; } return order; } ); const applyReturn = defineTool( { name: 'applyReturn', description: '为已发货或已签收的订单申请退货,生成退货单号。只有在用户明确要求退货且订单存在时才使用。', inputSchema: z.object({ orderId: z.string().describe('订单号,以ORD开头'), reason: z.string().describe('用户提供的退货原因,如果没有明确原因,填写"用户未说明"'), }), outputSchema: z.object({ returnId: z.string().optional(), error: z.string().optional(), }), }, async ({ orderId, reason }) => { if (!['ORD-001', 'ORD-002'].includes(orderId)) { return { error: '订单不存在,无法退货' }; } return { returnId: 'RTN-' + Math.random().toString(36).slice(2, 8).toUpperCase() }; } ); const customerAgent = defineAgent( { name: 'customerAgent', description: '售后客服代理,可以查询订单并处理退货申请', tools: [getOrder, applyReturn], systemPrompt: `你是某电商平台的售后客服。你的职责: 1. 用户询问订单信息时,先通过getOrder工具查询,不要猜测。 2. 用户要求退货时,先确认订单状态,再调用applyReturn。 3. 工具返回NOT_FOUND时,明确告诉用户查不到该订单。 4. 如果用户一次提了多个问题,拆解后按顺序处理。`, maxTurns: 5, temperature: 0.2, }, async (input) => { return input.text; } );

5.3 运行效果与日志解读

启动方式最简单的是用Genkit的dev运行时:

genkit start

它启动后会自动扫描项目里的agent,并在本地生成一个调试页面。我实测下来,代理处理“我要查ORD-001的物流信息,顺便想退货”这句话时,内部行为是这样的:

  1. 模型先调getOrder({ orderId: 'ORD-001' })。
  2. 工具返回“无线鼠标、已发货、2025-02-01”。
  3. 模型看到订单存在,继续调applyReturn({ orderId: 'ORD-001', reason: '用户未说明' })。
  4. 工具返回退货单号RTN-XXXX。
  5. 模型汇总两个工具结果,生成最终回复:“您的订单是无线鼠标,已发货。退货申请已提交,退货单号是RTN-XXXX。”

全程只进行了一次对话交互,但内部跑了两次工具调用,模型自己完成了“查订单-确认可以退货-申请退货-汇总回复”的四步决策链。如果你自己写这个循环,至少得写一百多行状态机代码,而且任何一步消息格式出错,对话就会中断。

6. 踩坑实录:多回合代理最常见的问题

6.1 代理陷入无限循环

常见的症状是:工具不断被调用,日志里出现同一参数的重复调用,直到maxTurns耗尽或者token爆掉。

我遇到的一个真实场景:用户问“我的订单什么时候到”,代理调用物流查询工具后,发现自己返回的字段不够详细,模型觉得“我还能再查一次”,于是再次调用同一个工具,结果返回的还是同样不详细的字段,死循环就出现了。

解决方式有三层:第一,设置maxTurns,这是最后的兜底。第二,工具返回的outputSchema要完整,避免模型觉得信息不够只能再查一次。第三,在systemPrompt里明确写“查询完成后直接回复用户,不要重复调用工具”。这三条组合起来,基本能根治大部分死循环。

6.2 上下文窗口被历史消息撑爆

多回合代理的history每次都会累积,时间长了token会暴涨。Genkit的Agent API默认会做token截断,但它是按模型上下文窗口的比例截断的,如果你给输出留的空间不够,模型可能会生成不完整的回复。

我现在的处理方式:

  • 定期把历史消息里过于冗长的工具结果做摘要压缩。工具返回的JSON如果有一屏长,后面根本不需要让模型再看完整版,压缩成“【工具结果】订单已发货,预计3月1日到达”反而更清晰。
  • 对用户输入做前置截断,比如超过2000字符就主动隔离。客服场景下用户很少打那么长的字,如果出现了,多半是复制粘贴了一堆无关内容,最好提醒用户精简。
  • 在关键节点主动引导模型“闲话少说”。比如退货完成后,model话已经生成完了,不再需要保留之前全量的工具交互记录。

6.3 工具参数解析失败或幻觉参数

模型经常把用户说的“发货日期”当成orderId传进来。我遇到的例子:用户说“我上个星期买的鼠标”,模型直接调getOrder({ orderId: '上个星期' }),工具返回NOT_FOUND,模型又晕了,不知道下一步怎么办。

解决手段:

  • 在inputSchema的describe里加约束,比如“订单号必须以ORD开头”。
  • 用z.string().regex(/^ORD-\d+$/)做硬校验,格式不对直接判校验失败。
  • 更好的做法是在工具内部做模糊识别,如果orderId不符合规范,返回一个引导性错误信息,比如“请提供以ORD开头的订单号”。模型看到这句提示后,通常会向用户重新索要订单号,而不是继续瞎猜。

6.4 并发与状态管理的坑

如果同一个Agent实例被并发请求调用,注意Genkit的Agent API本身是无状态的,历史消息是外部传入的。这意味着你必须在外面自己存会话状态。

我一开始直接把所有状态放内存,本地跑没问题,一到有并发请求的环境就乱套。后来改用Redis存history,key用sessionId,每次调用前拉取,调用后整体更新。代码如下:

async function runWithSession(sessionId: string, userText: string) { const history = await redis.get(`session:${sessionId}`) ?? []; history.push({ role: 'user', content: [{ text: userText }] }); const response = await customerAgent.run({ input: { text: userText }, history, }); history.push({ role: 'model', content: [{ text: response.text }] }); await redis.set(`session:${sessionId}`, history, { EX: 3600 }); return response.text; }

需要提醒的是:如果你显式传入history,那么history里的消息格式必须和Genkit生成的保持一致,尤其是工具结果消息。不要只存用户和AI的纯文本消息,否则代理会丢失“上一轮已经查过订单”的关键状态,多回合就退化成多轮单回合。

7. 进阶:从Demo到可用的多回合代理

7.1 持久化会话状态

除了Redis这种简单方案,Genkit还提供了更正式的SessionManager能力用来管理会话数据。但我个人觉得,项目早期用Redis或数据库直接搞定更直观,等接入的agent多了再考虑统一抽象。

持久化时有一点要特别留意:history不能只存数组再原样扔回去,因为如果代理中途调用了工具,history里包含的toolRequest和toolResult消息会非常多。你传回去的时候要确保消息数组的顺序完整,否则模型会看到一些孤立的工具结果却没有对应的调用记录,直接影响回复质量。

我的做法是尽量把“对话级历史”和“内部工具日志”区分开。对外只保留用户消息和最终AI回复,内部工具调用记录可以另存,但不必都塞进下一次请求。这样既省token,也减少模型被无关信息干扰的可能。

7.2 流式输出与用户中断

真实产品需要打字机效果和“用户中途打断”的能力。Genkit的Agent API支持流式:

const stream = customerAgent.stream({ input: { text: '查订单ORD-001' }, }); for await (const chunk of stream) { // chunk类型包含 partialOutput、toolRequest、toolResult、finalResponse if (chunk.toolRequest) { console.log('正在调用工具:', chunk.toolRequest.name); } if (chunk.finalResponse) { process.stdout.write(chunk.finalResponse.text ?? ''); } }

这样做的好处是:前端可以实时展示“代理正在查询订单...”,而不是让用户盯着空白界面等好几秒。当模型频繁调用工具时,这种体验差别非常明显。

流式模式下,用户中断逻辑自己实现起来也简单:前端断开连接时,直接终止当前的agent.run循环即可,不需要在代理内部做复杂的取消机制。

7.3 可观测性:用Genkit Trace调试

我最推荐的调试方式:打开Genkit的Debug页面。启动本地开发服务后,每次agent.run都会生成一条trace,里面能看到每一个步骤的耗时、输入输出,比如模型推理、工具调用、工具结果、再次模型输出。

多回合代理调试时,trace的价值极大,因为它能清楚画出整个决策链。你可以看到模型为什么调错工具,是在哪一步开始幻觉的,也可以看到工具返回的数据有没有被模型正确理解。

实操建议:让测试人员或者你自己在Debug页面把trace导出成JSON存下来。发现代理效果变差时,对比历史trace,很快能定位是prompt变化、工具Schema变化,还是模型参数变化导致的。

我自己的小习惯是:每迭代一版多回合代理,都会先跑一组固定脚本,覆盖正常流程、工具异常、模型幻觉三种case,把输出和trace存下来。等哪天代理行为“莫名其妙变了”,回放这些case能快速定位问题。多回合代理的复杂度比普通聊天机器人高一个量级,没有trace和测试脚本兜底,上线后你就会被各种反馈淹没。这个习惯帮我省了至少一半的调试时间。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询