新闻详情

新闻详情

首页 / 资讯中心 / 详情

Genkit 多回合 AI 代理实战:TypeScript 与 Firestore 状态管理

发布时间:2026/9/26 16:06:06来源:尧图网络
Genkit 多回合 AI 代理实战:TypeScript 与 Firestore 状态管理
1. 为什么多回合代理值得单独拿出来讲多回合 AI 代理这个概念这两年被聊得很多但真正落到代码层面很多人第一反应还是不就是把历史消息拼起来再发给模型吗。我一开始也这么想直到自己动手做一个需要连续追问、带工具调用、还要记住用户偏好的助手时才发现事情远没有这么简单。单次问答的链路是线性的输入、推理、输出结束。而多回合代理的链路是一个带状态的循环每一轮都要考虑上一轮留下了什么、这一轮该调用哪个工具、下一轮用户可能追问什么。状态管理、工具编排、上下文裁剪、错误恢复这四件事只要有一件没处理好代理就会表现得像个失忆的客服。这篇内容围绕Genkit 的代理 API展开讲清楚怎么用TypeScript把多回合 AI 代理搭起来并且用Firestore做持久化的会话状态。适合已经写过简单 LLM 调用、想往能记住、能调工具、能连续对话方向走一步的开发者。如果你之前只写过单轮 prompt看完应该能直接照着搭一个可运行的多回合代理如果你已经用过别的编排框架也能对比出 Genkit 这套 API 在类型安全和状态管理上的取舍。需要先说明一点Genkit 是 Google 开源的一套 AI 应用开发框架代理 API 是它里面专门用来处理带工具、带状态、多轮次这类场景的抽象。它不是一个黑盒底层还是把消息、工具调用、模型响应这些概念显式暴露给你只是帮你把循环和状态这两块最容易写乱的部分收敛了。我下面讲的很多细节是基于常见工程实践补全的因为官方文档给的是骨架真正跑起来要填的肉得自己长。2. 多回合代理到底难在哪先想清楚再写代码2.1 单轮和多轮的本质区别单轮调用可以理解成函数给一个输入返回一个输出无副作用。多回合代理更像对象它有内部状态方法调用会改变状态下一次调用依赖上一次的结果。这个区别决定了你不能用写单轮的方式去写多轮。具体来说多回合代理要额外处理四件事。第一是会话状态也就是这一轮对话属于哪个用户、哪个会话历史消息存在哪。第二是工具调用循环模型可能连续调用多个工具每次调用完要把结果喂回去让它继续推理直到它决定给出最终回答。第三是上下文窗口管理历史消息不能无限堆得按策略裁剪或摘要。第四是错误与中断恢复工具调用失败、模型超时、用户中途改主意这些都得有兜底。我见过不少人写多回合代理就是把 messages 数组一直 push然后每次全量发给模型。小规模测试没问题一旦对话超过二三十轮token 成本飙升模型还会因为上下文太长开始遗忘早期关键信息。这就是没做上下文管理的典型症状。2.2 为什么选 Genkit 而不是自己手搓循环自己手搓一个多回合循环不是不行我早期就这么干过。核心逻辑大概是这样维护一个 messages 数组调用模型检查返回里有没有 tool_calls有就执行工具、把结果 append 回去、再调用模型循环直到没有 tool_calls。听起来简单但真正写起来光是工具调用的参数校验和多轮之间的类型一致性就能耗掉大量时间。Genkit 的代理 API 在这几个点上给了明确抽象。它用 TypeScript 的类型系统把工具定义、输入输出 schema 都约束住工具调用的参数在编译期就能查出类型错误而不是等到运行时模型返回一个字段名拼错的 JSON 才报错。它的代理抽象把循环这件事内置了你定义好工具和提示词代理自己会处理调用工具、拿结果、继续推理这个循环。状态这块它和 Firestore 的集成也比较顺会话数据可以直接落库。选它的核心理由是类型安全和状态管理的内置支持。如果你团队里 TypeScript 用得多这两个点能省下大量调试时间。代价是你要接受它的抽象方式有些高度定制化的循环逻辑可能得绕一下但对绝大多数多回合场景它的默认行为已经够用。2.3 技术选型对照方案状态管理工具调用类型安全上手成本手搓循环自己实现自己实现取决于自己低但坑多Genkit 代理 API内置 Firestore内置循环强中等通用编排框架需额外配置需额外配置一般中等偏高这张表不是要贬低别的方案而是想说清楚如果你的核心诉求是快速搭一个类型安全、状态可持久化的多回合代理Genkit 的路径是最短的。如果你需要极其特殊的循环控制手搓反而更自由。3. 环境搭建与核心概念对齐3.1 初始化项目与依赖先把项目骨架搭起来。我用的是 Node 20 加 TypeScript 5.x这个组合在 Genkit 生态里比较稳。mkdir multi-turn-agent cd multi-turn-agent npm init -y npm install genkit genkit-ai/googleai genkit-ai/firebase firebase-admin npm install -D typescript tsx types/node npx tsc --inittsconfig.json里几个关键项要确认。target设成ES2022module设成NodeNextmoduleResolution也设成NodeNext。这里插一句最近 TypeScript 圈子里在讨论moduleResolutionnode10和baseUrl这两个选项被标记弃用的事如果你看到编译警告别慌把moduleResolution换成NodeNext、用paths替代baseUrl的别名功能就行。这不是本文重点但踩到了会卡住新手。{ compilerOptions: { target: ES2022, module: NodeNext, moduleResolution: NodeNext, strict: true, esModuleInterop: true, skipLibCheck: true, outDir: dist } }strict一定要开。多回合代理里类型错误一旦漏到运行时排查成本极高因为错误可能出现在第三轮工具调用返回的数据结构上而你盯着第一轮的代码看半天。3.2 Genkit 的几个核心概念在写代码前得先把 Genkit 的几个概念对齐不然后面看代码会懵。Flow是 Genkit 里的基本执行单元你可以把它理解成一个带类型签名的函数输入输出都有 schema 约束。Tool是代理可以调用的工具每个工具有名字、描述、输入 schema、输出 schema以及一个执行函数。Agent是代理 API 的核心它把模型、工具、状态管理串起来对外暴露一个给输入、拿输出的接口内部处理多轮循环。Session是会话用来隔离不同用户或不同对话线程的状态。这几个概念的关系是Agent 使用 ToolAgent 的状态存在 Session 里Agent 本身可以包装成一个 Flow 对外提供服务。理清这层关系后面写代码就是填空。3.3 配置模型与 Firestore模型这块我用的是 Gemini 系列通过genkit-ai/googleai接入。Firestore 用firebase-admin初始化本地开发可以用 Firestore 模拟器省得连真实数据库。import { genkit } from genkit; import { googleAI } from genkit-ai/googleai; import { initializeApp } from firebase-admin/app; import { getFirestore } from firebase-admin/firestore; initializeApp(); export const db getFirestore(); export const ai genkit({ plugins: [googleAI()], model: googleai/gemini-1.5-flash, });提示本地开发强烈建议用 Firestore 模拟器启动命令是firebase emulators:start --only firestore然后在环境变量里设FIRESTORE_EMULATOR_HOSTlocalhost:8080。这样测试会话状态不会污染真实数据也不会产生费用。模型选 flash 还是 pro取决于你的场景。多回合代理如果工具调用频繁flash 的响应速度和成本更友好如果推理链复杂pro 更稳。我一般先用 flash 跑通遇到推理质量不够再换。4. 定义工具代理的手和脚4.1 工具定义的基本结构工具是代理和外部世界交互的接口。Genkit 里定义工具用ai.defineTool核心是四样东西名字、描述、输入 schema、输出 schema外加执行函数。import { z } from genkit; export const getWeather ai.defineTool( { name: getWeather, description: 查询指定城市的当前天气输入城市名返回温度和天气状况, inputSchema: z.object({ city: z.string().describe(城市名称例如 北京), }), outputSchema: z.object({ city: z.string(), temperature: z.number(), condition: z.string(), }), }, async (input) { // 实际项目里这里调用真实天气 API return { city: input.city, temperature: 22, condition: 晴, }; } );这里有个细节值得说description不是写给人看的注释是写给模型看的。模型决定要不要调用这个工具、怎么填参数全靠这段描述。描述写得含糊模型就会乱调或者不调。我踩过的坑是描述里没写清楚参数格式结果模型把北京和北京市当成两个不同的输入反复调用。4.2 工具描述怎么写才不坑模型工具描述要回答三个问题这个工具干什么、什么时候该用、参数长什么样。我总结了一个模板实测下来模型调用准确率明显提升。第一句说功能查询指定城市的当前天气第二句说时机当用户询问天气、气温、是否下雨时使用第三句说参数city 参数传城市中文名不要带市字后缀第三句这种约束特别重要。模型很听话你写清楚它基本就照做你不写它就自由发挥。多回合场景下工具被调用的次数多参数格式不统一会让后续处理逻辑变得很脏。4.3 工具的错误处理工具执行函数里一定要处理异常。模型调用工具时如果工具抛异常整个代理循环可能中断。正确做法是捕获异常并返回一个结构化的错误结果让模型知道这次调用失败了它可以选择重试或者换个方式回答。async (input) { try { const data await fetchWeather(input.city); return { city: input.city, temperature: data.temp, condition: data.cond }; } catch (err) { return { city: input.city, temperature: -1, condition: 查询失败 }; } }返回-1和查询失败不是最优解但比抛异常好。更好的做法是在 output schema 里加一个error字段让模型明确知道这是错误状态。这个取舍看你的场景如果工具失败很罕见简单兜底就够如果失败常见就得设计完整的错误语义。5. 构建代理把模型、工具、状态串起来5.1 代理的核心定义代理的定义是整篇内容的核心。Genkit 的代理 API 让你把模型、工具、系统提示词打包成一个可调用的单元。import { getWeather } from ./tools/weather.js; export const assistantAgent ai.defineAgent({ name: assistantAgent, model: googleai/gemini-1.5-flash, system: 你是一个多回合助手。你可以调用工具来获取信息。 当用户的问题需要实时数据时先调用工具再回答。 记住用户在对话中提到的偏好后续回答要参考这些偏好。, tools: [getWeather], });system提示词里那句记住用户在对话中提到的偏好是关键。多回合代理的记忆分两层一层是消息历史框架自动维护另一层是用户偏好这类结构化信息需要你显式提取和存储。前者是短期记忆后者是长期记忆。很多人只做了前者结果代理能记住上一句说了什么但记不住用户三回合前说过自己不吃辣。5.2 会话状态怎么存会话状态我用 Firestore 存结构设计成这样interface SessionDoc { sessionId: string; userId: string; messages: Array{ role: user | model | tool; content: string; timestamp: number; }; preferences: Recordstring, string; createdAt: number; updatedAt: number; }messages存对话历史preferences存提取出来的用户偏好。分开存的原因是两者的生命周期不同消息历史会越来越长需要裁剪偏好是稳定的键值对可以长期保留。读写会话的封装export async function loadSession(sessionId: string): PromiseSessionDoc | null { const doc await db.collection(sessions).doc(sessionId).get(); return doc.exists ? (doc.data() as SessionDoc) : null; } export async function saveSession(session: SessionDoc): Promisevoid { session.updatedAt Date.now(); await db.collection(sessions).doc(session.sessionId).set(session); }注意Firestore 单文档有 1MB 大小限制。消息历史如果无限增长早晚会撞到这个上限。所以裁剪策略不是可选项是必选项。5.3 上下文裁剪的三种策略上下文裁剪我试过三种策略各有适用场景。第一种是滑动窗口只保留最近 N 轮消息。实现最简单缺点是早期重要信息会丢。适合闲聊类场景。第二种是摘要压缩把早期消息用模型总结成一段话替换掉原始消息。保留信息多但每次摘要都要调模型有额外成本。适合需要长期记忆的场景。第三种是关键信息提取把用户偏好、事实性信息抽成结构化字段单独存消息历史照常裁剪。这是我最推荐的因为它把长期记忆和短期上下文解耦了。用户说我叫张三这条信息进preferences消息历史里那条可以裁掉但代理依然记得用户叫张三。实际项目里我一般组合用滑动窗口保最近 10 轮关键信息提取保长期偏好摘要压缩作为可选补充。6. 多回合循环的完整实现6.1 一轮对话的完整流程把前面几块拼起来一轮对话的流程是这样的根据 sessionId 加载会话状态把用户新消息 append 到消息历史裁剪上下文到合理长度调用代理代理内部处理工具调用循环从代理响应里提取新的用户偏好更新preferences把模型回复 append 到消息历史保存会话状态返回回复给调用方这个流程里第 4 步是框架帮你做的其余都是你要写的。看起来步骤多但每一步都很薄加起来代码量可控。6.2 调用代理并处理工具循环export async function chat(sessionId: string, userInput: string) { let session await loadSession(sessionId); if (!session) { session { sessionId, userId: default, messages: [], preferences: {}, createdAt: Date.now(), updatedAt: Date.now(), }; } session.messages.push({ role: user, content: userInput, timestamp: Date.now(), }); const trimmed trimContext(session.messages, 10); const response await assistantAgent.run({ messages: trimmed.map((m) ({ role: m.role model ? model : user, content: [{ text: m.content }], })), context: session.preferences, }); const replyText response.text; session.messages.push({ role: model, content: replyText, timestamp: Date.now(), }); await saveSession(session); return replyText; }assistantAgent.run这一步框架内部会自动处理模型决定调用工具、执行工具、把结果喂回模型、模型继续推理这个循环。你不需要手写 while 循环这是代理 API 相对手搓的最大省事之处。6.3 用户偏好提取偏好提取我单独做了一个小函数用模型从用户输入里抽结构化信息。这一步可以异步做不阻塞主回复。export async function extractPreferences( userInput: string, existing: Recordstring, string ): PromiseRecordstring, string { const result await ai.generate({ prompt: 从下面这句话里提取用户的长期偏好以 JSON 返回。 已有偏好${JSON.stringify(existing)} 用户输入${userInput} 只提取明确的、长期有效的偏好不要提取临时信息。, output: { schema: z.record(z.string()) }, }); return { ...existing, ...result.output }; }只提取长期有效的偏好这句约束很重要。用户说我今天想吃火锅是临时信息不该进偏好用户说我不吃辣是长期偏好该进。不写清楚模型会把所有信息都塞进去偏好库很快就变成垃圾场。7. 常见问题与排查实录7.1 工具不被调用或反复调用这是最高频的问题。症状是模型该调工具时不调或者同一个工具反复调。排查顺序是这样先看工具描述。描述里有没有明确说什么时候用没有的话模型不知道时机就容易漏调。再看参数 schema。参数类型和描述是否清晰模型填错参数会导致调用失败失败后它可能重试看起来就是反复调用。最后看系统提示词。提示词里有没有引导模型需要实时数据时先调工具没有的话模型可能倾向于直接编答案。我遇到过一次模型反复调用天气工具原因是工具返回的condition字段是英文而用户问的是中文模型觉得没查到就一直重试。把返回字段改成中文后问题消失。这个坑很隐蔽本质是工具输出和用户语言不一致导致模型判断失误。7.2 上下文超长导致响应变慢或质量下降对话轮次多了之后响应变慢、答非所问基本都是上下文太长。排查方法是打印每次发给模型的消息数量和总字符数。如果超过模型上下文窗口的 70%就该裁剪了。裁剪策略前面讲过这里补充一个实操技巧裁剪时不要把工具调用的中间消息裁掉只裁用户和模型的对话消息。工具调用消息裁掉会导致模型看到用户问了个问题然后直接是答案中间推理链断了后续回答质量会下降。7.3 Firestore 读写延迟影响体验Firestore 每次读写都有网络往返如果每轮对话都同步读写延迟会累积。优化方法是把会话状态在内存里缓存一份只在对话结束时或每隔几轮才落库。但这样有丢状态的风险如果服务崩溃未落库的部分就丢了。我的取舍是用户消息和模型回复立即落库偏好提取异步落库。前者保证对话不丢后者丢了大不了下次重新提取。这个取舍取决于你的场景对数据一致性的要求。7.4 常见问题速查表症状可能原因排查方向工具不调用描述缺时机说明补全工具描述工具反复调用返回格式不匹配检查输出字段语言和类型响应变慢上下文过长打印消息数加裁剪答非所问早期信息被裁关键信息提取到偏好状态丢失落库时机不对检查保存逻辑类型报错schema 不一致对齐工具输入输出 schema8. 几个提升代理质量的实操心得8.1 系统提示词要分层写系统提示词我习惯分三层写角色层、能力层、约束层。角色层说你是什么能力层说你能调哪些工具、什么时候调约束层说你不能做什么。分层写的好处是改的时候知道改哪层不会牵一发动全身。约束层特别容易被忽略。比如不要编造工具没返回的数据这条不写的话模型在工具失败时会自己编一个答案用户根本分不清真假。多回合场景下这种编造更危险因为它会被存进历史后续轮次都基于错误信息推理。8.2 给代理加思考步骤让模型在调用工具前先输出一段简短推理能显著提升工具调用的准确率。做法是在系统提示词里加一句在调用工具前先用一句话说明你为什么需要这个工具。这段推理会出现在响应里你可以选择展示给用户也可以只用于调试。我实测下来加了这一步之后工具误调用率大概降了一半。原因是模型被迫想清楚再动手而不是看到关键词就条件反射调工具。8.3 会话隔离要彻底多用户场景下sessionId 的生成和校验必须严格。我见过有人用 userId 直接当 sessionId结果同一用户开两个对话窗口消息串在一起了。正确做法是 sessionId 独立生成和 userId 是多对一关系一个用户可以有多个会话。Firestore 的集合结构也要注意sessions集合下每个文档用 sessionId 做 key文档里存 userId。查询某用户的所有会话时用where(userId, , userId)。这样隔离清晰也方便后续做会话列表功能。8.4 日志要记全多回合代理的调试难度比单轮高一个量级因为问题可能出现在任意一轮。我的做法是每轮对话都记一条结构化日志包含 sessionId、轮次、用户输入、工具调用记录、模型回复、耗时。出问题时按 sessionId 一查整条链路清清楚楚。日志里工具调用记录尤其重要。模型调了什么工具、传了什么参数、拿到什么结果这三样记全了90% 的工具相关问题都能快速定位。9. 后续可以怎么扩展这套骨架跑通之后扩展方向挺多的。一个是加流式输出让模型回复逐字返回体验会好很多Genkit 对 streaming 有支持接进来不难。另一个是加多代理协作比如一个负责查资料的代理加一个负责总结的代理通过代理 API 组合起来。还有就是评估体系给代理准备一批测试用例每次改提示词或工具后跑一遍看通过率变化避免改一处坏一处。我自己下一步打算做的是把偏好提取做成独立的异步任务队列主对话流程完全不阻塞偏好更新延迟几秒对体验影响不大但能让主流程更快。这个改动不大但对高频对话场景的体验提升明显。最后分享一个小技巧调试多回合代理时把每轮发给模型的完整消息数组打印出来存成 JSON 文件。出问题时对比你以为发出去的消息和实际发出去的消息十有八九能发现裁剪逻辑或者消息拼接的问题。这个习惯帮我省了无数个小时。
网站建设高端定制企业官网
RELATED

相关资讯

更多精彩内容,欢迎继续阅读

较早相关资讯

最新相关资讯

VS2019 编译好的 ceres lib 与 dll 配置指南:从链接错误到 Hello Ceres 2026/9/26 16:41:48

VS2019 编译好的 ceres lib 与 dll 配置指南:从链接错误到 Hello Ceres

简介:这份资源是使用VS2019编译完成的Ceres依赖库文件集合,面向需要在Windows平台配置Ceres Solver的C开发者与视觉SLAM、三维重建方向的学习者,可解决自行编译依赖库耗时长、环境易出错的问题。压缩包共432个文件,约17.35MB&…

阅读更多 →
基于机器学习的加密恶意流量检测平台实战:从特征工程到ONNX量化部署 2026/9/26 16:41:48

基于机器学习的加密恶意流量检测平台实战:从特征工程到ONNX量化部署

简介:这是一套面向计算机、人工智能、通信工程等专业学生与安全方向学习者的加密恶意流量分析与检测平台源码,可作为毕业设计、课程设计或项目立项演示的完整参考方案。项目以机器学习方法为核心,围绕流量特征提取、模型训练与检测展示构建了…

阅读更多 →
VS2019编译Ceres Solver:lib与dll配置实战指南 2026/9/26 16:41:48

VS2019编译Ceres Solver:lib与dll配置实战指南

简介:这份资源是使用VS2019编译完成的Ceres依赖库集合,面向需要在Windows平台配置Ceres Solver的开发者与学习者,尤其适合正在搭建SLAM、光束法平差或非线性优化项目的同学。压缩包内共432个文件,包含328个h头文件、40个dll动态库…

阅读更多 →
AI导航与语义SLAM技术进展:TaoToken统一Key接入ROS2 Nav2的配置与验证 2026/9/26 16:41:36

AI导航与语义SLAM技术进展:TaoToken统一Key接入ROS2 Nav2的配置与验证

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

阅读更多 →
支持Function Call的本地ollama模型对比评测:开发代理agent的配置与验证 2026/9/26 16:41:29

支持Function Call的本地ollama模型对比评测:开发代理agent的配置与验证

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

阅读更多 →
自建CRM系统实战:从Docker部署到团队落地全流程复盘 2026/9/26 16:41:29

自建CRM系统实战:从Docker部署到团队落地全流程复盘

客户信息分散在微信聊天、邮件、Excel表格和个人便签里,需要回看半年前的沟通记录时,得来回切换四五个窗口,最后仍然拼不出完整过程——这是我决定认真部署一套CRM系统的直接导火索。DeskcommCRM 是我近期从选型、部署到逐步推广给团队使用的…

阅读更多 →

今日资讯

本周资讯

本月资讯

看完文章仍有疑问?

联系尧图顾问,获取一对一建站咨询

立即免费咨询 📞 400-888-8888
📞 ✉