☰
Genkit多回合AI代理实战:TypeScript与Firestore会话管理
2026/9/26 11:50:48 网站建设 项目流程

1. 为什么多回合AI代理值得你花时间折腾

多回合AI代理这个概念,这两年在开发者圈子里被讨论得越来越多。简单说,它就是一个能记住上下文、能连续对话、能根据历史信息做决策的AI程序。跟那种问一句答一句的“单次问答”不一样,多回合代理更像是一个能跟你聊上半小时还不忘前面说过什么的助手。而Genkit的代理API,就是帮你把这件事做扎实的一套工具。

我最初接触Genkit是因为一个客服自动化的项目。当时用传统的请求-响应模式,每次用户追问“那刚才那个订单呢”,模型就一脸茫然,因为上下文丢了。后来换成Genkit的代理API配合Firestore做会话存储,整个体验才顺起来。这篇文章就是把我踩过的坑、调过的参数、以及最终跑通的方案完整拆开讲一遍。

适合谁看?如果你写过TypeScript,对AI接口调用有基本概念,想做一个能记住对话历史、能处理多轮追问的代理,那这篇内容就是给你准备的。如果你完全没碰过TypeScript,建议先补一下基础语法,不然读起来会有点吃力。全文会围绕Genkit代理API的核心机制、Firestore的会话管理、TypeScript的类型定义、以及实际部署中的参数调优来展开,每个环节都会给出可直接复用的代码和配置。

2. Genkit代理API的核心机制拆解

2.1 代理API到底解决了什么问题

传统的AI调用模式是这样的:你发一个prompt,模型返回一个结果,结束。下一次调用跟上一次没有任何关系。这种模式在简单场景下够用,比如翻译一句话、生成一段文案。但一旦涉及多回合交互,问题就暴露了。

多回合代理的核心需求有三个:第一,记住历史对话;第二,根据历史做推理;第三,在合适的时候触发工具调用。Genkit的代理API把这三个需求抽象成了几个关键概念:Session、Turn、Tool、以及State。Session代表一次完整的对话生命周期,Turn代表其中的一轮交互,Tool是代理可以调用的外部能力,State是贯穿整个Session的共享数据。

我打个比方。传统调用像去快餐店点餐,你说一个汉堡,店员给你一个汉堡,交易结束。多回合代理像去一家熟悉的餐厅,服务员记得你上次点了什么、对什么过敏、喜欢靠窗坐。Genkit的代理API就是帮你把这家餐厅的服务流程标准化的一套框架。

2.2 Genkit的架构选型逻辑

Genkit选择TypeScript作为主要开发语言,这个决策背后有很实际的考量。TypeScript的类型系统能在编译阶段就发现很多接口定义上的错误,这在构建复杂代理时特别重要。比如你定义了一个Tool的输入类型是{ orderId: string },但调用的时候传了{ orderId: number },TypeScript会直接报错,不用等到运行时才发现。

Firestore作为会话存储的选择也很合理。它是一个文档型数据库,天然适合存储JSON结构的数据。对话历史本质上就是一个不断追加的JSON数组,每条消息包含角色、内容、时间戳。Firestore的实时监听能力还能让你在多个客户端之间同步对话状态,这在做多端应用时非常有用。

注意:Firestore的免费额度对于开发阶段完全够用,但生产环境要提前算好读写次数。每次对话至少产生一次写操作,如果日活用户上千,费用需要提前评估。

2.3 多回合代理的状态管理模型

状态管理是多回合代理最容易出问题的地方。我见过不少项目,对话到第五六轮就开始胡言乱语,根本原因就是状态没管好。Genkit的代理API采用了一种分层状态模型:Session级别的全局状态、Turn级别的临时状态、以及Tool调用产生的副作用状态。

Session级别的状态存储整个对话的元信息,比如用户ID、对话开始时间、当前轮次计数。Turn级别的状态只在当前这一轮有效,比如用户刚刚输入的内容、模型正在生成的中间结果。Tool调用产生的状态需要显式地合并回Session状态,否则下一轮就丢了。

这个模型的好处是职责清晰。你不需要把所有东西都塞进一个大对象里,而是按生命周期分层管理。实际操作中,我会把用户偏好、历史摘要这类长期有效的信息放在Session状态里,把当前问题的临时解析结果放在Turn状态里。

3. 环境搭建与TypeScript工程配置

3.1 初始化项目与依赖安装

先把项目骨架搭起来。我习惯用pnpm,速度快、磁盘占用小。如果你用npm或者yarn也完全没问题,命令替换一下就行。

mkdir genkit-agent-demo cd genkit-agent-demo pnpm init pnpm add genkit @genkit-ai/googleai @genkit-ai/firebase firebase-admin pnpm add -D typescript tsx @types/node

这里解释一下每个依赖的作用。genkit是核心库,提供代理API和流程编排能力。@genkit-ai/googleai是模型接入层,我用的Gemini系列模型,你也可以换成其他支持的模型。@genkit-ai/firebase提供Firestore的集成插件。firebase-admin是服务端操作Firestore的SDK。tsx用来直接运行TypeScript文件,省去编译步骤。

3.2 TypeScript配置的坑与最佳实践

TypeScript的配置文件看起来简单,但有几个选项直接影响开发体验。我踩过的坑主要集中在模块解析和路径别名上。

{ "compilerOptions": { "target": "ES2022", "module": "NodeNext", "moduleResolution": "NodeNext", "strict": true, "esModuleInterop": true, "skipLibCheck": true, "outDir": "./dist", "rootDir": "./src", "baseUrl": ".", "paths": { "@/*": ["src/*"] } }, "include": ["src/**/*"] }

关于baseUrl和moduleResolution,最近TypeScript社区有不少讨论。moduleResolution: "node10"确实已经被标记为弃用,新项目建议直接用NodeNext或者Bundler。baseUrl虽然还没完全移除,但如果你只用paths做路径别名,其实可以省略baseUrl,直接在paths里写相对路径。我实测下来,去掉baseUrl之后配置更干净,也不影响别名解析。

提示:如果你在团队里推行TypeScript编码规范,建议把strict设为true,并且开启noUncheckedIndexedAccess。这两个选项能帮你提前发现大量潜在的空值错误。

3.3 Firestore的初始化与安全规则

Firestore的初始化分两部分:服务端用firebase-admin,客户端用firebase SDK。做代理服务通常只需要服务端初始化。

import { initializeApp, cert } from 'firebase-admin/app'; import { getFirestore } from 'firebase-admin/firestore'; const serviceAccount = JSON.parse(process.env.FIREBASE_SERVICE_ACCOUNT || '{}'); initializeApp({ credential: cert(serviceAccount), }); export const db = getFirestore();

安全规则方面,服务端SDK走的是管理员权限,不受安全规则限制。但如果你有客户端直接读Firestore的需求,安全规则必须写严谨。我一般会设置成只允许用户读写自己的对话文档。

rules_version = '2'; service cloud.firestore { match /databases/{database}/documents { match /sessions/{sessionId} { allow read, write: if request.auth != null && request.auth.uid == resource.data.userId; } } }

4. 多回合代理的核心实现细节

4.1 定义代理的输入输出类型

TypeScript的类型定义是代理开发的地基。我习惯先定义清楚数据契约,再写业务逻辑。这样后面改起来心里有底。

import { z } from 'genkit'; export const MessageSchema = z.object({ role: z.enum(['user', 'model', 'tool']), content: z.string(), timestamp: z.number(), toolCallId: z.string().optional(), }); export const SessionStateSchema = z.object({ sessionId: z.string(), userId: z.string(), messages: z.array(MessageSchema), metadata: z.record(z.unknown()).default({}), turnCount: z.number().default(0), }); export type Message = z.infer<typeof MessageSchema>; export type SessionState = z.infer<typeof SessionStateSchema>;

用Zod来定义Schema有个好处:它既是TypeScript的类型来源,又是运行时的校验器。Genkit的Tool定义也支持Zod Schema,这样输入输出都能自动校验。我试过手写类型再单独写校验逻辑,维护成本高很多,后来全部换成Zod统一管理。

4.2 会话存储的读写策略

Firestore的读写策略直接影响代理的响应速度和成本。我的做法是:每次Turn开始时读取一次Session文档,Turn结束时写回一次。中间的所有操作都在内存里完成。

import { db } from './firebase'; const SESSIONS_COLLECTION = 'sessions'; export async function loadSession(sessionId: string): Promise<SessionState | null> { const doc = await db.collection(SESSIONS_COLLECTION).doc(sessionId).get(); if (!doc.exists) return null; return doc.data() as SessionState; } export async function saveSession(state: SessionState): Promise<void> { await db.collection(SESSIONS_COLLECTION) .doc(state.sessionId) .set(state, { merge: true }); }

这里用merge: true是为了避免覆盖掉其他字段。比如你后面加了新的元数据字段,老文档没有这个字段,merge写入不会把老数据清掉。

注意:Firestore单文档有1MB的大小限制。对话历史如果太长,需要做截断或者摘要。我的做法是保留最近20轮完整对话,更早的内容用模型生成一段摘要存起来。

4.3 代理流程的编排与工具调用

Genkit的defineFlow是编排代理逻辑的核心。它把输入校验、模型调用、工具调用、状态更新串成一条流水线。

import { genkit, z } from 'genkit'; import { googleAI } from '@genkit-ai/googleai'; const ai = genkit({ plugins: [googleAI()], model: 'googleai/gemini-2.0-flash', }); export const chatFlow = ai.defineFlow( { name: 'chatFlow', inputSchema: z.object({ sessionId: z.string(), userId: z.string(), message: z.string(), }), outputSchema: z.object({ reply: z.string(), turnCount: z.number(), }), }, async (input) => { let session = await loadSession(input.sessionId); if (!session) { session = { sessionId: input.sessionId, userId: input.userId, messages: [], metadata: {}, turnCount: 0, }; } session.messages.push({ role: 'user', content: input.message, timestamp: Date.now(), }); const history = session.messages.slice(-20).map((m) => ({ role: m.role === 'model' ? 'model' : 'user', content: [{ text: m.content }], })); const response = await ai.generate({ prompt: history, tools: [weatherTool, orderLookupTool], }); session.messages.push({ role: 'model', content: response.text, timestamp: Date.now(), }); session.turnCount += 1; await saveSession(session); return { reply: response.text, turnCount: session.turnCount, }; } );

这段代码里有几个关键点。第一,历史消息截取最近20条,防止prompt过长。第二,工具列表在每次generate时传入,模型会根据上下文决定是否调用。第三,状态更新在模型返回之后统一写回,减少Firestore的写次数。

4.4 工具定义与参数校验

工具是代理能力的延伸。定义工具的时候,描述字段要写得足够清楚,模型才能正确判断什么时候该调用。

export const weatherTool = ai.defineTool( { name: 'getWeather', description: '查询指定城市的当前天气情况,当用户询问天气时调用', inputSchema: z.object({ city: z.string().describe('城市名称,例如:北京、上海'), }), outputSchema: z.object({ temperature: z.number(), condition: z.string(), }), }, async (input) => { const data = await fetchWeatherAPI(input.city); return { temperature: data.temp, condition: data.condition, }; } );

description字段我一般会写两句话:一句说明功能,一句说明触发时机。实测下来,这样模型调用工具的准确率明显更高。另外,输入参数的describe也要写清楚,模型会根据描述来提取参数值。

5. 实操过程中的问题排查与调优

5.1 对话历史丢失的排查思路

对话历史丢失是最常见的问题。表现是代理突然“失忆”,不记得前面说过什么。排查的时候按这个顺序来:先确认Firestore里有没有数据,再确认读取逻辑有没有问题,最后确认写入时机对不对。

我遇到过一次,Firestore里数据明明在,但代理就是读不到。查了半天发现是sessionId在客户端生成的时候带了特殊字符,Firestore的文档ID不允许某些字符,写入的时候被静默替换了。后来统一用UUID生成sessionId,问题就没了。

还有一种情况是并发写入导致的覆盖。两个请求同时读取同一个Session,各自修改后写回,后写的把先写的覆盖了。解决办法是用Firestore的事务或者乐观锁。我一般会在Session里加一个version字段,写入前检查版本号。

5.2 模型回复质量下降的调优手段

多回合对话到后面几轮,模型回复质量下降,通常是因为历史消息太长,模型注意力被稀释了。我的调优手段有三个:截断历史、生成摘要、调整温度参数。

截断历史最简单,保留最近N轮。摘要稍微复杂一点,用模型把早期对话压缩成一段话。温度参数方面,多回合对话建议用0.3到0.7之间的值,太低会显得死板,太高容易跑偏。

问题表现可能原因调优手段
回复越来越短历史消息过长截断到最近15轮
重复之前的内容温度过低温度调到0.5以上
答非所问工具描述不清重写工具description
忘记关键信息摘要丢失检查摘要生成逻辑

5.3 Firestore读写成本的优化技巧

Firestore按读写次数计费,对话量大了之后成本不可忽视。我总结了几条优化经验。第一,合并写入。一个Turn只写一次,不要每加一条消息就写一次。第二,用批量写入处理多个文档。第三,读操作尽量走缓存,同一个Session在短时间内多次读取可以用内存缓存顶一下。

const sessionCache = new Map<string, { data: SessionState; expiry: number }>(); export async function loadSessionCached(sessionId: string): Promise<SessionState | null> { const cached = sessionCache.get(sessionId); if (cached && cached.expiry > Date.now()) { return cached.data; } const data = await loadSession(sessionId); if (data) { sessionCache.set(sessionId, { data, expiry: Date.now() + 30000 }); } return data; }

缓存时间设30秒,对于连续对话场景足够覆盖大部分重复读取。但要注意,写入的时候必须清掉缓存,否则会读到脏数据。

5.4 常见错误速查表

错误信息根因解决方式
PERMISSION_DENIED服务账号权限不足检查IAM角色,补上Firestore权限
INVALID_ARGUMENT文档ID含非法字符用UUID替换自定义ID
RESOURCE_EXHAUSTED超出配额检查读写次数,加缓存
DEADLINE_EXCEEDED模型响应超时加超时重试,或换更快的模型
TypeError: Cannot read property类型定义与实际数据不符用Zod做运行时校验

6. 从开发到上线的关键决策

6.1 部署形态的选择

Genkit的代理可以部署成多种形态:独立的Node服务、Serverless函数、或者嵌入到现有后端里。我做过对比,独立Node服务适合对话量稳定的场景,Serverless适合波动大的场景,嵌入现有后端适合已经有成熟基础设施的团队。

独立Node服务的优势是可控性强,WebSocket长连接、流式响应都好实现。Serverless的优势是弹性伸缩,但冷启动对首轮响应有影响。我现在的项目用的是独立Node服务加容器化部署,配合健康检查和自动重启,稳定性满足要求。

6.2 监控与日志的落地

代理上线之后,没有监控就是盲人摸象。我至少会记录这几类指标:每轮对话的响应时间、工具调用成功率、Firestore读写次数、模型token消耗量。日志方面,每轮对话的输入输出都要留痕,方便排查问题。

function logTurn(sessionId: string, turnCount: number, duration: number, toolCalls: number) { console.log(JSON.stringify({ event: 'turn_complete', sessionId, turnCount, durationMs: duration, toolCalls, timestamp: new Date().toISOString(), })); }

结构化日志的好处是可以直接接入日志分析平台,做聚合和告警。我一般会设置响应时间超过5秒就告警,工具调用失败率超过10%也告警。

6.3 安全与合规的底线

代理服务涉及用户对话数据,安全底线必须守住。第一,所有对话数据加密存储,Firestore默认加密,但敏感字段建议应用层再加密一次。第二,访问控制要严格,服务账号权限最小化。第三,对话数据设置过期时间,定期清理。

提示:如果业务涉及个人隐私数据,建议在存储前做脱敏处理,并且明确告知用户数据用途。合规不是技术问题,但技术方案要配合合规要求。

7. 我踩过的那些坑和最终沉淀的经验

做多回合代理这一年多,踩的坑比写的代码还多。最开始我试图把所有对话历史都塞进prompt里,结果token消耗爆炸,响应慢得没法用。后来改成滑动窗口加摘要,才把成本和体验平衡好。

还有一个坑是工具调用的死循环。模型调用工具拿到结果后,又觉得需要再调一次,来回好几次。解决办法是在工具描述里明确写清楚“调用一次即可获得完整结果”,并且在流程里加最大调用次数限制。

TypeScript的类型体操也让我吃过亏。一开始为了追求类型安全,把类型定义写得极其复杂,结果编译时间越来越长,开发体验反而下降。后来我遵循一个原则:对外接口用严格类型,内部实现允许适当宽松。这样既保证了契约清晰,又不至于被类型系统拖累。

Firestore的实时监听功能很诱人,但不要滥用。我试过用实时监听同步对话状态,结果每次写入都触发一次回调,回调里又有写入,差点搞出无限循环。后来改成只在客户端需要实时更新时开启监听,服务端一律用普通读取。

最后分享一个小技巧:在开发阶段,把每轮对话的完整状态打印到控制台,包括历史消息、工具调用记录、模型原始响应。这个习惯帮我快速定位了至少一半的bug。上线前再把日志级别调高,避免输出敏感信息。

这个方案后续还可以扩展的方向包括:接入向量数据库做长期记忆、增加多模态输入支持、以及实现多代理协作。但那是另一个话题了,先把单代理的多回合对话做扎实,比什么都重要。

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

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

立即咨询