ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

Genkit多回合AI代理实战:TypeScript+Firebase构建有状态会话系统

2026/9/28 16:27:05 拓冰建站 浏览量
Genkit多回合AI代理实战:TypeScript+Firebase构建有状态会话系统 1. 为什么多回合代理值得单独拿出来做多回合 AI 代理这个概念这两年从论文里的 demo 一路卷到了生产环境。我最早接触类似需求是在做一个客服工单自动分流的项目当时用单次问答的方式硬扛结果用户一句“刚才那个订单帮我改下地址”就直接把模型干懵了——它根本不知道“刚才那个订单”指的是什么。这就是单回合和多回合最本质的差别多回合代理需要维护会话状态、理解上下文指代、并在多轮交互中保持目标一致性。Genkit 是 Google 开源的一套 AI 应用开发框架它的代理 APIAgent API专门用来处理这类有状态、多步骤的交互场景。和直接调模型 API 不同Genkit 的代理 API 把工具调用、状态管理、回合控制这些东西做了抽象你不需要自己从零搭一套会话管理逻辑。配合 Firebase 做持久化和部署TypeScript 做类型约束整条链路是通的。这篇文章适合谁看如果你已经写过简单的 LLM 调用但一遇到“多轮对话”“工具调用”“状态保持”就开始堆 if-else那这篇就是写给你的。我会从设计思路讲到实操细节包括我踩过的坑和实测有效的参数配置。全文基于 TypeScript Genkit Firebase 这套组合展开代码可以直接抄。2. 整体架构设计与技术选型考量2.1 为什么选 Genkit 而不是自己撸一套自己撸一套多回合代理不是不行但你要处理的东西比想象中多得多。我列一下核心要解决的问题会话状态的存储与恢复、工具调用的编排与结果回传、回合边界的判定什么时候该继续调工具什么时候该给用户回话、错误重试与超时控制、多轮上下文的截断策略。这些东西每一个单独做都不难但凑在一起就是几百行胶水代码而且很容易在边界情况上翻车。Genkit 的代理 API 把这些抽象成了几个核心概念Flow定义一次完整的代理执行流程、Tool可被代理调用的外部能力、Session会话状态容器、Turn单次回合。你只需要定义好工具和流程逻辑状态管理和回合控制框架帮你处理。实测下来同样的多回合客服场景用 Genkit 比手写省了大概 60% 的代码量而且可维护性完全不是一个级别。另一个选它的理由是 Firebase 集成。Genkit 原生支持部署到 Firebase Functions会话状态可以直接存 Firestore不需要额外搭 Redis 或者数据库。对于中小规模的应用来说这套组合的运维成本几乎为零。2.2 TypeScript 在这套架构里扮演什么角色TypeScript 不是可选项是必选项。多回合代理涉及大量的数据结构传递——工具的输入输出、会话状态的结构、回合之间的上下文。没有类型约束的话你会在运行时才发现某个字段拼错了而那时候已经调了三轮模型token 也烧了。Genkit 的 TypeScript SDK 提供了完整的类型推导。你定义一个 Tool 的时候输入输出的 schema 用 Zod 定义TypeScript 会自动推导出类型。代理调用工具的时候如果参数类型不匹配编译期就报错了。这个体验在重构的时候尤其爽——改一个字段名所有引用它的地方都会标红不用担心漏改。注意如果你用的是 TypeScript 5.x 以上版本baseUrl和moduleResolutionnode10这两个选项已经被标记为弃用会在 7.0 中移除。新建项目直接用moduleResolution: bundler或者node16别再用老配置了否则升级的时候又是一堆迁移工作。2.3 多回合代理的核心数据流整个数据流可以拆成四个阶段。第一阶段是会话初始化用户第一次请求进来创建一个 Session生成 sessionId把初始状态写入 Firestore。第二阶段是回合执行代理接收用户输入判断是否需要调用工具如果需要就执行工具并把结果回传给模型循环直到模型决定给出最终回复。第三阶段是状态持久化每个回合结束后把更新后的会话状态写回 Firestore。第四阶段是响应返回把最终回复返回给用户同时带上 sessionId 供下一轮使用。这个流程看起来简单但第二阶段是真正的难点。模型什么时候该调工具、调哪个工具、工具返回结果后怎么决定下一步这些都需要在 Flow 里定义清楚。Genkit 的做法是让你定义一个generate循环每次模型返回后检查是否有 tool call有就执行然后继续循环没有就退出。这个循环的终止条件设置很关键后面会详细讲。3. 核心细节解析与实操要点3.1 工具定义让代理知道它能做什么工具是代理的手和脚。没有工具代理只能聊天有了工具它才能查数据库、调 API、改状态。Genkit 定义工具的方式很直观用defineTool加上 Zod schemaimport { defineTool } from genkit-ai/core; import { z } from zod; export const queryOrderTool defineTool( { name: queryOrder, description: 根据订单号查询订单详情包括状态、金额、收货地址, inputSchema: z.object({ orderId: z.string().describe(订单号格式为 ORD- 开头加 8 位数字), }), outputSchema: z.object({ orderId: z.string(), status: z.enum([pending, shipped, delivered, cancelled]), amount: z.number(), address: z.string(), }), }, async (input) { const order await db.collection(orders).doc(input.orderId).get(); if (!order.exists) { throw new Error(订单 ${input.orderId} 不存在); } return order.data() as OrderData; } );这里有几个细节值得展开。description字段非常重要模型就是靠这个描述来判断什么时候该调这个工具。我见过太多人把 description 写成“查询订单”结果模型在该调的时候不调不该调的时候乱调。正确的做法是把触发条件和参数格式都写清楚比如上面写的“根据订单号查询订单详情”模型就知道用户提到订单号的时候应该调这个工具。inputSchema里的.describe()也不是装饰它会作为参数说明传给模型。订单号格式这种约束写进去模型在提取参数的时候会准确很多。实测下来加了 describe 之后参数提取的错误率从大概 15% 降到了 3% 左右。实操心得工具的数量不要一次性给太多。我试过给代理挂 12 个工具结果模型的选择准确率明显下降经常调错工具。后来拆成两组每组 5-6 个准确率就回来了。如果业务确实需要很多工具考虑做一层路由先让模型判断意图再加载对应的工具集。3.2 会话状态管理Firestore 的读写策略会话状态存 Firestore 是 Genkit Firebase 组合的默认方案但怎么存、存什么、什么时候存这些都需要设计。我的做法是把会话状态分成两部分对话历史和业务状态。对话历史就是消息列表业务状态是代理在执行过程中积累的中间结果比如已经查到的订单信息、用户确认过的参数等。对话历史直接存消息数组每条消息包含 role、content、timestamp。业务状态用一个灵活的 map 结构key 是业务标识value 是任意 JSON。这样设计的好处是对话历史可以按需截断控制 token 消耗业务状态可以长期保留跨会话恢复。interface SessionState { sessionId: string; userId: string; messages: Array{ role: user | model | tool; content: string; timestamp: number; }; businessState: Recordstring, unknown; createdAt: number; updatedAt: number; turnCount: number; }写入策略上我建议每个回合结束后写一次而不是每收到一条消息就写。因为一个回合可能包含多次工具调用和模型交互中间状态没必要持久化只有回合结束时的最终状态才需要落盘。这样能把 Firestore 的写入次数降低 60% 以上成本也相应下降。读取策略上有个坑要注意Firestore 的读取有延迟如果你在回合执行过程中频繁读取会话状态可能会读到旧数据。我的做法是在回合开始时一次性读出完整状态在内存中操作回合结束时一次性写回。这样既避免了读写不一致也减少了网络往返。3.3 回合控制什么时候该停回合控制是多回合代理最容易出问题的地方。核心问题是模型返回了一个 tool call你执行了工具把结果回传给模型模型又返回了一个 tool call……这个循环什么时候停Genkit 的默认行为是设置一个最大回合数超过就强制停止。但这个默认值往往不够用因为不同场景需要的回合数差别很大。查订单可能两轮就够了但一个复杂的退换货流程可能需要七八轮。我的做法是动态设置最大回合数根据用户意图的复杂度来调整。具体实现上我在 Flow 的入口处做一个简单的意图分类把用户请求分成简单查询、中等复杂度操作、复杂流程三类分别对应 3、6、10 的最大回合数。这个分类不需要很精确用关键词匹配就够了目的是给一个合理的上限防止无限循环。另一个终止条件是模型主动结束。当模型返回的响应中没有 tool call只有文本内容时说明它认为已经可以给用户回复了这时候就退出循环。这个判断在 Genkit 里是自动的你只需要检查响应里有没有 toolRequests 字段。注意一定要设置超时。我遇到过模型陷入循环的情况一直在调同一个工具每次返回的结果都一样但模型就是不停。后来加了 30 秒的超时超时后强制返回当前状态并提示用户“处理超时请重试”。这个兜底逻辑在生产环境是必须的。4. 完整实操流程与关键环节实现4.1 项目初始化与依赖配置先把项目搭起来。用 Firebase CLI 初始化一个 Functions 项目然后装 Genkit 相关的依赖npm init -y npm install genkit-ai/core genkit-ai/ai genkit-ai/firebase genkit-ai/googleai zod npm install -D typescript types/node firebase-functions firebase-adminTypeScript 配置这块tsconfig.json里几个关键选项{ compilerOptions: { target: ES2022, module: node16, moduleResolution: node16, strict: true, esModuleInterop: true, skipLibCheck: true, outDir: lib, rootDir: src }, include: [src] }moduleResolution用node16而不是node10因为后者已经弃用了。strict一定要开多回合代理的类型安全全靠它。skipLibCheck建议开能省不少编译时间第三方库的类型问题不用你操心。4.2 定义代理 Flow 的完整代码Flow 是整个代理的入口。我把它拆成三个部分状态加载、回合循环、状态保存。import { genkit, z } from genkit; import { firebase } from genkit-ai/firebase; import { googleAI } from genkit-ai/googleai; const ai genkit({ plugins: [firebase(), googleAI()], model: googleAI.model(gemini-1.5-flash), }); export const agentFlow ai.defineFlow( { name: multiTurnAgent, inputSchema: z.object({ sessionId: z.string().optional(), userId: z.string(), message: z.string(), }), outputSchema: z.object({ sessionId: z.string(), reply: z.string(), turnCount: z.number(), }), }, async (input) { const sessionId input.sessionId || generateSessionId(); const session await loadSession(sessionId, input.userId); session.messages.push({ role: user, content: input.message, timestamp: Date.now(), }); const maxTurns estimateMaxTurns(input.message); let turnCount 0; let finalReply ; while (turnCount maxTurns) { turnCount; const response await ai.generate({ model: googleAI.model(gemini-1.5-flash), messages: session.messages, tools: [queryOrderTool, updateAddressTool, cancelOrderTool], returnToolRequests: true, }); if (response.toolRequests response.toolRequests.length 0) { for (const toolRequest of response.toolRequests) { const toolResult await executeTool(toolRequest, session); session.messages.push({ role: tool, content: JSON.stringify(toolResult), timestamp: Date.now(), }); } continue; } finalReply response.text; session.messages.push({ role: model, content: finalReply, timestamp: Date.now(), }); break; } session.turnCount turnCount; session.updatedAt Date.now(); await saveSession(session); return { sessionId, reply: finalReply || 处理超时请重试, turnCount, }; } );这段代码有几个关键点。returnToolRequests: true让模型返回工具调用请求而不是直接执行这样你可以在执行前做一些校验。executeTool是一个分发函数根据 toolRequest 的 name 找到对应的工具并执行。estimateMaxTurns根据消息内容估算最大回合数简单实现就是关键词匹配。4.3 工具执行与错误处理工具执行看起来简单但错误处理很讲究。工具可能因为各种原因失败网络超时、数据不存在、权限不足。这些错误如果直接抛给模型模型可能会反复重试同一个工具浪费回合数。我的做法是把工具错误包装成结构化的结果返回给模型而不是抛异常async function executeTool(toolRequest: ToolRequest, session: SessionState) { try { const tool toolMap[toolRequest.name]; if (!tool) { return { error: 未知工具: ${toolRequest.name} }; } const result await tool.run(toolRequest.input); session.businessState[toolRequest.name] result; return { success: true, data: result }; } catch (err) { const message err instanceof Error ? err.message : 未知错误; return { success: false, error: message }; } }这样模型收到的是{ success: false, error: 订单不存在 }它就知道这个工具调用失败了可以选择换一个工具或者直接告诉用户。实测下来这种结构化错误比抛异常的方式模型处理成功率高了大概 40%。4.4 部署到 Firebase Functions部署这块Genkit 提供了onFlow包装器直接把 Flow 暴露成 HTTP 接口import { onFlow } from genkit-ai/firebase/functions; export const agent onFlow(ai, agentFlow, { region: asia-east1, memory: 512MiB, timeoutSeconds: 60, minInstances: 0, maxInstances: 10, });region选离用户近的国内用户建议asia-east1。memory给 512MiB 够用了代理本身不占多少内存主要是模型调用的网络开销。timeoutSeconds设 60 秒因为多回合代理可能跑好几轮每轮都要调模型时间累积起来不短。minInstances设 0 省钱但冷启动会有几秒延迟如果对响应时间敏感可以设 1。实操心得Firestore 的读写权限一定要配好。默认规则是拒绝所有读写你需要加上针对会话集合的规则。我建议用 Firebase Auth 做用户认证然后在 Firestore 规则里校验request.auth.uid和会话的userId是否匹配防止越权访问别人的会话。5. 常见问题与排查技巧实录5.1 模型不调工具或者调错工具这是最高频的问题。表现是用户明确说了“帮我查订单 ORD-12345678”模型却回复“请问您的订单号是多少”。原因通常是工具的 description 不够明确或者模型没有正确理解用户意图。排查步骤先看模型的原始响应确认它有没有识别出 tool call。如果没有检查工具的 description 是否包含了触发条件。如果 description 没问题检查消息历史里是否有干扰信息。我遇到过一次因为之前的对话里模型已经问过订单号用户回复了订单号但模型把这条消息当成了普通文本而不是工具参数。解决方法是在系统提示里明确告诉模型“当用户提供订单号时直接调用 queryOrder 工具不要再次询问。”这个提示加进去之后问题基本消失了。5.2 会话状态丢失或错乱表现是第二轮对话时代理完全不记得第一轮说了什么。原因通常是 sessionId 没有正确传递或者 Firestore 写入失败但没有被捕获。排查步骤先确认客户端有没有把上一轮返回的 sessionId 带回来。然后检查 Firestore 里对应的文档是否存在。如果文档存在但内容不对检查写入逻辑是不是在回合结束前就执行了。我踩过的一个坑是在回合循环中间写了一次 Firestore结果循环还没结束状态就被覆盖了。后来改成只在回合结束后写一次问题解决。5.3 回合数超限但问题没解决表现是代理跑了最大回合数但用户的问题还是没解决最后返回“处理超时”。原因可能是工具调用一直失败模型在反复重试。排查步骤看日志里工具调用的成功率。如果某个工具一直失败先修工具。如果工具正常但模型还是在循环检查是不是工具返回的结果格式模型理解不了。我遇到过一次工具返回的是嵌套很深的 JSON模型解析不了就一直重试。后来把返回结果扁平化问题解决。5.4 常见问题速查表问题现象可能原因排查方法解决方案模型不调工具description 不明确检查工具描述补充触发条件和参数格式调错工具工具数量过多检查工具列表拆分工具集或加路由层会话状态丢失sessionId 未传递检查客户端请求确保每轮带回 sessionId状态错乱写入时机不对检查写入逻辑只在回合结束后写入回合超限工具反复失败查看工具成功率修复工具或扁平化返回结果响应超时回合数过多检查 maxTurns动态调整最大回合数5.5 性能优化的几个实操技巧第一个技巧是消息历史截断。多轮对话的消息列表会越来越长token 消耗直线上升。我的做法是保留最近 10 轮对话更早的对话做摘要压缩。摘要用模型生成把关键信息提取出来替换掉原始消息。这样 token 消耗能降低 50% 以上而且不影响上下文理解。第二个技巧是工具结果缓存。同一个会话里如果同一个工具用同样的参数调用了多次直接返回缓存结果不用重新执行。这个在查询类工具上特别有效能省不少数据库查询。第三个技巧是并行工具调用。如果模型一次返回了多个 tool call而且这些工具之间没有依赖关系可以并行执行。Genkit 支持Promise.all的方式并行执行工具实测能把回合时间缩短 30%-40%。6. 从单轮到多轮我的实战体会多回合代理和单轮问答最大的区别在于状态。单轮问答是无状态的每次请求都是独立的多回合代理是有状态的每一轮都建立在前面的基础上。这个区别听起来简单但实际做起来状态管理会渗透到每一个环节。我刚开始做的时候习惯性地用单轮思维去写代码结果就是到处传状态、到处读数据库代码乱得没法维护。后来想明白了状态应该集中管理回合逻辑应该纯粹。会话状态就是一个对象在回合开始时加载在回合结束时保存中间的所有操作都在内存里完成。这样代码清晰调试也方便。另一个体会是错误处理要比单轮更细致。单轮问答出错了用户重试一次就行多回合代理出错了用户可能已经聊了五轮重试成本很高。所以每个工具调用都要有错误处理每个回合都要有超时控制每个状态写入都要有失败重试。这些在单轮场景下可以偷懒在多回合场景下偷懒就是给自己挖坑。最后分享一个调试技巧把每个回合的完整状态打印出来。包括模型输入、模型输出、工具调用、工具结果、状态变化。刚开始会觉得日志很多但出问题的时候这些日志就是救命稻草。我现在的做法是在开发环境把日志级别调到 debug生产环境调到 info既能排查问题又不会太吵。这套东西我前后迭代了大概两个月从最初的单轮硬扛到现在的多回合代理中间踩的坑基本都写在这了。代码可以直接抄但参数和配置要根据自己的业务场景调整。特别是 maxTurns 和超时时间不同场景差别很大建议先跑一批测试用例根据实际数据来定。