基于Genkit代理API构建多回合AI代理:TypeScript与Firestore实战
发布时间:2026/10/1 23:26:53来源:尧图网络
1. 为什么多回合代理值得单独拿出来做多回合 AI 代理这个概念这两年被聊得很多但真正落到代码层面很多人第一反应还是“不就是把历史消息拼起来再发给模型吗”。我一开始也这么想直到真正做一个需要连续追问、状态保持、工具调用的场景时才发现单靠手写消息数组很快就会乱成一锅粥。Genkit 的代理 API 就是在这个背景下进入我视野的——它把多回合会话、工具调用、状态持久化这几件事收敛成了一套相对清晰的抽象。先说清楚这个内容适合谁看。如果你已经写过一些调用大模型的代码知道什么是 prompt、什么是 function calling但一到“让代理记住上一轮说了什么、并且根据上下文决定下一步调哪个工具”就开始头疼那这篇就是写给你的。如果你完全没接触过 TypeScript也能看但需要边看边补一点类型系统的基础否则后面 Firestore 的数据结构部分会有点吃力。Genkit 是 Google 开源的一套 AI 应用开发框架核心卖点是“用写普通函数的方式写 AI 流程”。它的代理 API 并不是一个独立的神秘组件而是建立在 Genkit 的 flow、tool、retriever 这些基础能力之上的组合。多回合代理的关键难点从来不是“调用模型”这一步而是三件事会话状态存哪里、工具调用结果怎么回灌、多轮之间的上下文怎么裁剪。Genkit 的代理 API 主要帮你解决前两件第三件仍然需要你自己拿捏。我这次做的项目是一个内部用的运维问答代理用户可以用自然语言问服务器状态、查日志、触发一些只读的诊断脚本。它必须支持多回合因为用户经常是“先问 A看到结果后再追问 B”而且中间会夹杂工具调用。技术栈就是 TypeScript Genkit FirestoreFirestore 用来存会话历史。下面我把整个思路、踩过的坑、以及可以直接抄的代码结构都摊开讲。2. 整体设计与思路拆解2.1 为什么选 Genkit 而不是自己拼消息数组自己拼消息数组最大的问题是状态管理会渗透到业务逻辑的每一个角落。你写一个函数处理用户输入函数内部要读历史、拼 prompt、调模型、判断是否要调工具、调完工具再拼一次、再调模型、最后写回历史。这一套下来业务代码和会话管理代码完全缠在一起测试都没法写。Genkit 的做法是把“代理”抽象成一个有状态的执行单元。你定义好工具tool和代理的指令prompt代理 API 负责在每一轮里决定是直接回复还是调用工具调用完把结果作为新的消息追加进会话再继续推理直到模型给出最终答复。这个循环对你是透明的你只需要关心工具怎么实现、会话存哪里。选它的第二个理由是类型安全。Genkit 的 flow 和 tool 都带 Zod schema输入输出在编译期就能校验。多回合代理最容易出的 bug 就是工具返回的数据结构和模型期望的不一致有了 schema这类问题在写代码时就会暴露而不是等到线上跑出奇怪的结果才发现。第三个理由是它和 Firestore 的契合度。Genkit 本身不强制你用某个存储但它的会话数据结构是明确定义的序列化到 Firestore 非常自然。我试过用别的框架会话对象里塞了一堆不可序列化的东西存 Firestore 时各种报错Genkit 这边基本没遇到。2.2 多回合代理的核心状态到底有哪些很多人以为多回合就是存一个 messages 数组其实远不止。一个能正常工作的多回合代理至少维护四类状态对话消息用户说了什么、模型回了什么、工具返回了什么按时间顺序排列。工具调用记录哪一轮调了哪个工具、参数是什么、结果是什么。这部分有时候和消息混在一起但排查问题时单独看更清楚。会话元数据会话 ID、创建时间、最后活跃时间、用户标识。这些不参与推理但决定了会话怎么查、怎么清理。代理内部状态比如某些代理需要记住“用户已经确认过某个操作”这类状态不属于对话内容但影响后续决策。Genkit 的代理 API 主要管第一类和第二类第三类要你自己在 Firestore 里设计第四类需要你用自定义的 state 机制。我一开始只存了消息结果遇到“用户说‘就按刚才那个来’”时代理完全不知道“刚才那个”指什么因为那个决策没有以消息形式留下痕迹。后来我把关键决策也显式写进消息问题才解决。2.3 会话存储选 Firestore 的取舍Firestore 适合这个场景的原因很直接它是文档型数据库一个会话就是一个文档消息数组就是文档里的一个字段读写都是原子的。而且它有实时监听能力如果以后要做“多个客户端同时看一个会话”直接就能用。但它也有代价。Firestore 单个文档有大小限制消息多了会撑爆。我的处理方式是会话文档只保留最近 N 轮消息更早的归档到子集合或者直接截断。N 取多少要看你的场景我这边取 20 轮因为运维问答通常不会聊太久超过 20 轮的基本是新问题了。另一个代价是查询成本。Firestore 按读写次数计费如果每一轮都全量读会话文档再全量写回消息一多成本就上去了。我的优化是只追加新消息用 Firestore 的 arrayUnion 操作避免全量覆盖。这个细节后面实操部分会展开。3. 核心细节解析与实操要点3.1 Genkit 代理 API 的基本构成Genkit 里定义一个代理核心是defineFlow加上工具定义。代理本身不是一个类而是一个 flow这个 flow 接收用户输入和会话状态返回模型回复和更新后的会话状态。工具用defineTool定义每个工具有名字、描述、输入 schema、输出 schema、以及执行函数。这里有个容易忽略的点工具的描述description是给模型看的不是给人看的。模型根据描述决定要不要调这个工具、怎么填参数。所以描述要写得像给一个聪明但完全不了解你系统的同事解释这个工具干什么。我见过有人把描述写成“查询函数”模型根本不知道查什么、什么时候该查结果工具从来没被调用过。代理的指令prompt也有讲究。多回合场景下指令里要明确告诉模型“你可以调用工具”“调用工具后要根据结果继续回答”“如果信息不足要追问”。这些看起来是废话但不写模型真的会偷懒要么不调工具直接编答案要么调完工具就把原始结果丢给用户不做整理。3.2 工具调用的参数设计工具参数设计直接决定代理好不好用。我的经验是参数要少、要扁平、要有默认值。模型填参数的能力有限参数一多就容易填错或者漏填。比如一个查询日志的工具如果参数是{service, level, startTime, endTime, keyword, limit}模型经常只填两三个。更好的做法是拆成多个工具或者把不常用的参数设默认值。参数类型也要注意。模型对枚举值的处理比自由字符串好得多。如果一个参数只能是几个固定值用 Zod 的 enum 定义模型填错的概率会大幅下降。我一开始用 string模型经常填出“error”“Error”“ERROR”这种大小写不一致的值改成 enum 后问题消失。还有一个坑是时间参数。模型对“最近一小时”这种相对时间的理解很不稳定有时候算错。我的做法是工具内部接受相对时间描述自己转换成绝对时间而不是让模型算好绝对时间传进来。这样模型只需要说“最近一小时”转换逻辑在我可控的代码里。3.3 会话状态的读写时机多回合代理最容易出错的地方就是状态读写时机。我的原则是一轮对话结束后一次性写入不要中间写。因为中间写会导致如果后面步骤失败会话状态处于一个不一致的状态下一轮读出来就是脏数据。具体来说一轮对话的流程是读会话 - 执行代理内部可能多次调模型和工具- 得到最终回复 - 把用户消息、工具调用记录、模型回复一起追加到会话 - 写回 Firestore。这样即使代理执行到一半崩了会话状态还是上一轮的干净状态用户重试即可。但这里有个例外如果工具调用有副作用比如触发了一个耗时任务那工具执行完就应该记录不能等整轮结束。否则任务触发了但会话没记录用户下一轮问“刚才那个任务怎么样了”代理完全不知道。我的处理是把有副作用的工具单独标记执行后立即写一条工具调用记录其余消息仍然整轮结束再写。3.4 上下文裁剪的策略消息不能无限增长必须裁剪。裁剪策略有好几种我试过三种按轮数裁剪只保留最近 N 轮。简单但可能丢掉重要的早期信息。按 token 数裁剪估算消息的 token 数超过阈值就从最老的开始删。更精确但需要 tokenizer。摘要裁剪把老消息用模型总结成一段摘要保留摘要加最近消息。效果最好但多一次模型调用成本和延迟都上去了。我最后选了按轮数裁剪加关键信息保留。具体做法是每轮消息打一个important标记裁剪时优先保留带标记的消息其余按轮数删。标记怎么打我让模型在回复时顺便判断这轮是否包含关键决策包含就打标记。这个判断本身也可能出错但比完全不裁剪好。注意裁剪后的会话再喂给模型时要确保工具调用和工具结果成对出现。如果裁剪把工具调用删了但留下了结果模型会困惑。Genkit 的消息结构里工具调用和结果是关联的裁剪时要按对处理。4. 实操过程与核心环节实现4.1 环境准备与依赖安装先建项目Node 版本建议 20 以上Genkit 对 Node 版本有要求。初始化mkdir genkit-agent-demo cd genkit-agent-demo npm init -y npm install genkit genkit-ai/googleai genkit-ai/firebase zod npm install -D typescript tsx types/node这里genkit-ai/googleai是模型提供方插件你也可以换成别的。genkit-ai/firebase提供 Firestore 相关的工具。Zod 用来定义 schema。tsconfig 里记得开strictGenkit 的类型定义在 strict 模式下才能发挥最大作用。另外moduleResolution建议用bundler或node16否则有些子路径导入会报错。配置模型插件import { genkit } from genkit; import { googleAI } from genkit-ai/googleai; export const ai genkit({ plugins: [googleAI()], model: googleai/gemini-1.5-flash, });模型选 flash 还是 pro 看场景。多回合代理如果工具调用频繁flash 的延迟优势很明显但复杂推理会弱一些。我的做法是默认 flash遇到需要深度推理的轮次再切 pro这个切换可以在代理内部根据输入判断。4.2 定义工具以查询服务状态为例import { z } from zod; import { ai } from ./genkit; export const getServiceStatus ai.defineTool( { name: getServiceStatus, description: 查询指定服务的当前运行状态包括是否在线、CPU 和内存使用率。当用户询问某个服务是否正常时使用此工具。, inputSchema: z.object({ serviceName: z.enum([api, worker, scheduler, db]), }), outputSchema: z.object({ online: z.boolean(), cpu: z.number(), memory: z.number(), lastCheck: z.string(), }), }, async ({ serviceName }) { // 实际实现里这里调用你的监控系统 return { online: true, cpu: 0.42, memory: 0.68, lastCheck: new Date().toISOString(), }; } );注意 description 的写法我特意写了“当用户询问某个服务是否正常时使用此工具”这是给模型的触发提示。inputSchema 用 enum 限制服务名避免模型编出不存在的服务。再定义一个查日志的工具演示相对时间处理export const queryLogs ai.defineTool( { name: queryLogs, description: 查询指定服务最近的日志。当用户想看错误信息或排查问题时使用。, inputSchema: z.object({ serviceName: z.enum([api, worker, scheduler, db]), level: z.enum([error, warn, info]).default(error), recentMinutes: z.number().min(1).max(1440).default(60), }), outputSchema: z.object({ lines: z.array(z.string()), total: z.number(), }), }, async ({ serviceName, level, recentMinutes }) { const since new Date(Date.now() - recentMinutes * 60 * 1000); // 实际实现里按 since 和 level 查日志 return { lines: [...], total: 0 }; } );recentMinutes让模型传相对时间工具内部算绝对时间这样模型不用做时间算术。4.3 定义代理 flow代理 flow 的核心是接收会话状态和用户输入返回新状态和回复import { z } from zod; import { ai } from ./genkit; import { getServiceStatus, queryLogs } from ./tools; const MessageSchema z.object({ role: z.enum([user, model, tool]), content: z.string(), toolName: z.string().optional(), important: z.boolean().optional(), }); const SessionSchema z.object({ sessionId: z.string(), messages: z.array(MessageSchema), updatedAt: z.string(), }); export const agentFlow ai.defineFlow( { name: opsAgent, inputSchema: z.object({ sessionId: z.string(), userInput: z.string(), }), outputSchema: z.object({ reply: z.string(), session: SessionSchema, }), }, async ({ sessionId, userInput }) { const session await loadSession(sessionId); const messages [ ...session.messages.map((m) ({ role: m.role, content: [{ text: m.content }] })), { role: user, content: [{ text: userInput }] }, ]; const { text, toolRequests } await ai.generate({ model: googleai/gemini-1.5-flash, system: 你是一个运维助手。你可以调用工具查询服务状态和日志。调用工具后要根据结果整理成人类可读的回答。如果用户的问题信息不足主动追问。, messages, tools: [getServiceStatus, queryLogs], }); // 处理工具调用 // ...见下一节 const newMessages [ ...session.messages, { role: user as const, content: userInput }, { role: model as const, content: text }, ]; const newSession { ...session, messages: newMessages, updatedAt: new Date().toISOString(), }; await saveSession(newSession); return { reply: text, session: newSession }; } );这里简化了工具调用的处理实际 Genkit 的 generate 返回里如果包含 toolRequests需要你手动执行工具再把结果作为新消息继续 generate直到没有 toolRequests 为止。这个循环是代理的核心我单独在下一节讲。4.4 工具调用循环的实现Genkit 不会自动帮你执行工具它只告诉你模型想调哪个工具、参数是什么。执行和回灌要你自己做async function runAgentLoop(initialMessages, tools, maxIterations 5) { let messages initialMessages; let iterations 0; while (iterations maxIterations) { const response await ai.generate({ model: googleai/gemini-1.5-flash, system: SYSTEM_PROMPT, messages, tools, }); if (!response.toolRequests || response.toolRequests.length 0) { return { text: response.text, messages }; } // 把模型的工具调用意图加入消息 messages.push({ role: model, content: response.toolRequests.map((t) ({ toolRequest: { name: t.name, input: t.input, ref: t.ref }, })), }); // 执行每个工具 for (const req of response.toolRequests) { const tool tools.find((t) t.name req.name); if (!tool) { messages.push({ role: tool, content: [{ toolResponse: { ref: req.ref, error: tool not found } }], }); continue; } try { const result await tool.run(req.input); messages.push({ role: tool, content: [{ toolResponse: { ref: req.ref, output: result } }], }); } catch (e) { messages.push({ role: tool, content: [{ toolResponse: { ref: req.ref, error: String(e) } }], }); } } iterations; } throw new Error(代理循环超过最大迭代次数); }maxIterations是必须的否则模型可能陷入“调工具 - 看到结果 - 再调同一个工具”的死循环。我设 5 次超过就报错同时把当前状态记下来方便排查。工具执行失败时不要把异常直接抛出去而是作为 toolResponse 的 error 字段回灌给模型。这样模型能看到“这个工具失败了”然后决定是换个工具还是告诉用户。直接抛异常会让整个代理崩掉用户体验很差。4.5 Firestore 会话存储实现会话存储用 Firestore 的文档结构就是 SessionSchema。读写import { getFirestore } from firebase-admin/firestore; const db getFirestore(); const COLLECTION agent_sessions; export async function loadSession(sessionId: string) { const doc await db.collection(COLLECTION).doc(sessionId).get(); if (!doc.exists) { return { sessionId, messages: [], updatedAt: new Date().toISOString() }; } return doc.data() as Session; } export async function saveSession(session: Session) { await db.collection(COLLECTION).doc(session.sessionId).set(session, { merge: true }); }这里用了merge: true避免覆盖掉其他字段。但注意messages 数组是全量覆盖的因为我在内存里已经拼好了完整数组。如果并发写同一个会话会有覆盖问题。我的处理是加一个乐观锁会话文档里存一个 version 字段写入时检查 version 是否变化变了就重试。运维场景并发不高这个方案够用。消息裁剪在 saveSession 里做function trimMessages(messages: Message[], maxRounds 20) { const important messages.filter((m) m.important); const recent messages.slice(-maxRounds * 2); const merged [...important, ...recent]; // 去重并按时间排序 const seen new Set(); return merged .filter((m) { const key ${m.role}:${m.content}; if (seen.has(key)) return false; seen.add(key); return true; }) .sort((a, b) (a as any).timestamp - (b as any).timestamp); }实际实现里消息要带 timestamp这里简化了。裁剪后要确保工具调用和结果成对我加了一个校验函数发现不成对就丢弃孤立的那个。5. 常见问题与排查技巧实录5.1 模型不调用工具怎么办这是最常见的问题。模型不调工具通常三个原因工具描述不清楚、系统提示没强调可以用工具、或者模型觉得直接回答更省事。排查顺序先看工具描述是不是写得太抽象。我见过描述写“查询数据”的模型完全不知道查什么数据。改成“查询指定服务的 CPU 和内存使用率当用户询问服务负载时使用”之后调用率立刻上去了。再看系统提示。如果系统提示里没提工具模型可能根本不知道有工具可用。Genkit 会把工具列表传给模型但系统提示里明确说“你可以调用工具”会显著提高调用率。最后看模型。有些小模型对工具调用的支持不好换个模型试试。我实测下来同一条提示不同模型的工具调用率能差一倍。5.2 工具调用参数填错参数填错的表现是工具执行报错或者返回空结果。排查时先把模型填的参数打日志看它到底填了什么。常见错误枚举值大小写不对、数字填成字符串、必填参数漏填。解决方式前面提过用 enum 限制取值范围、用 default 给可选参数兜底、参数名起得直观一点。另外可以在工具执行函数里加参数校验不合法就返回一个明确的错误信息模型看到错误信息后有时会自己纠正重试。5.3 会话状态不一致表现是代理“忘记”了之前说过的话或者重复问已经回答过的问题。原因通常是会话没写成功、或者裁剪把关键消息删了。排查时先看 Firestore 里的会话文档确认消息是不是完整。如果消息在但代理还是忘那就是裁剪问题检查 important 标记有没有打上。如果消息不在检查 saveSession 有没有被调用、有没有报错被吞掉。我遇到过一次是 saveSession 的 Promise 没 await函数提前返回了消息根本没写进去。这种低级错误在异步代码里很常见建议在 saveSession 里加日志确认每次写入都成功。5.4 代理循环死锁表现是代理一直调同一个工具或者两个工具来回调。原因是模型看到工具结果后不满意又调一次结果还是不满意循环。解决方式设 maxIterations超过就中断并返回一个兜底回复。同时在系统提示里加一句“如果工具返回的结果已经足够回答用户不要再调用工具”。另外检查工具返回的数据结构如果模型期望的是 A 但你返回的是 B它会一直重试。5.5 常见问题速查表问题可能原因排查动作解决方式模型不调工具描述不清、提示没强调看工具描述和系统提示改描述、加提示、换模型参数填错类型太自由、参数太多打日志看实际参数用 enum、加 default、拆工具会话丢失没写成功、裁剪误删查 Firestore 文档加 await、检查 important 标记循环死锁结果不满意、结构不匹配看迭代次数和工具返回设 maxIterations、对齐结构回复太啰嗦系统提示没约束看系统提示加“回答简洁”约束工具超时工具实现慢看工具执行耗时加超时、异步化5.6 几个我踩过的坑第一个坑是 Firestore 的 arrayUnion 和全量覆盖混用。我一开始想用 arrayUnion 追加消息省读写但后来发现裁剪需要全量覆盖两种方式混在一起导致消息顺序乱了。最后统一成全量覆盖简单可靠成本高一点但可接受。第二个坑是工具返回的数据太大。有一次查日志返回了几百行模型处理不了直接报错。后来我在工具里加了 limit默认只返回 20 行模型需要更多会再调一次。这个“分页”思路在多回合代理里很好用让模型自己决定要不要翻页。第三个坑是系统提示太长。我一开始把所有规则都塞进系统提示结果模型注意力被分散反而表现不好。后来精简到只保留最关键的几条效果反而更好。系统提示不是越长越好要像写产品需求一样只写必须的。第四个坑是没处理工具调用的并发。如果模型一轮里调了多个工具我一开始是串行执行的慢。改成 Promise.all 并行后快了很多但要注意工具之间如果有依赖就不能并行。我的做法是默认并行有依赖的工具在描述里注明让模型分轮调用。6. 性能与成本优化的一些实践多回合代理跑起来之后下一步就是优化。我主要从三个方向入手减少模型调用次数、减少 token 消耗、减少 Firestore 读写。减少模型调用次数的关键是让工具返回的信息足够完整避免模型反复调同一个工具。我在工具返回里加了一个summary字段用一句话概括结果模型看 summary 就能决定下一步不用解析完整数据。这个改动让平均调用次数从 3.2 次降到 2.1 次。减少 token 消耗主要靠裁剪和摘要。裁剪前面讲了摘要我试过但成本不划算因为摘要本身要调模型。后来我改成规则摘要把工具调用记录压缩成“调用了 X 工具结果是 Y”这种固定格式不调模型token 省了但信息保留。减少 Firestore 读写靠批量写。一轮对话结束后用户消息、工具记录、模型回复一起写一次写入搞定。读的时候如果会话不大一次读全量如果会话很大只读最近 N 轮更早的按需读。还有一个优化是缓存工具结果。有些工具查询的数据变化不频繁比如服务列表可以缓存几分钟。我在工具实现里加了简单的内存缓存命中率挺高模型调用工具的延迟明显下降。提示缓存要注意失效策略。我一开始缓存不过期结果服务状态变了代理还报旧数据被用户投诉。后来改成按数据类型设不同 TTL状态类 30 秒配置类 5 分钟问题解决。7. 后续可以扩展的方向这套东西跑通之后能扩展的地方不少。我列几个我实际考虑过的第一个是加流式输出。Genkit 支持流式多回合代理如果每轮都等完整回复用户等待感很强。改成流式后模型一边生成一边显示体验好很多。但流式和工具调用循环结合有点复杂需要处理“流到一半要调工具”的情况我还在摸索。第二个是加多代理协作。一个代理负责理解意图一个负责执行工具一个负责整理回复。Genkit 的 flow 可以嵌套理论上可行但状态传递会变复杂。我目前是单代理够用就没动。第三个是加评估。多回合代理的效果很难用单轮指标衡量需要构造多轮对话数据集看代理在每一轮的表现。这个工程量不小但要做生产级应用迟早得做。第四个是加人工介入。某些操作需要人工确认代理不能自己决定。我的做法是在工具里加一个requiresApproval标记遇到这种工具就暂停等人工确认后再继续。这个机制在运维场景很实用避免代理误操作。这套基于 Genkit 代理 API 的多回合代理我从零搭到能用大概花了一周其中大部分时间花在调试工具调用和会话状态上。如果你也在做类似的东西我的建议是先把单轮跑通再加多轮最后加工具。顺序反了会很难调。另外 Firestore 的会话结构一开始就设计好后面改起来很痛苦。工具描述多花点时间打磨能省下大量调试时间。
网站建设高端定制企业官网