项目概述:用 Mastra 和 Elasticsearch 构建具备代理能力的 AI 应用
先说说这个项目是干嘛的。简单讲,我要做一个能把大模型、业务逻辑和搜索能力串起来的 AI 应用,让它不只是"能聊天",还能自己规划、调用工具、翻历史记录,像一个小助手一样完成多步任务。技术底座用了两个东西:一个是Mastra,一个专门用来编排 AI 代理的框架;另一个是Elasticsearch,老牌搜索引擎,但它现在最强的身份其实是向量数据库——我们既要用它存业务数据的索引,又要用它做语义检索和记忆存储。
这个项目适合谁看?两类人。第一类是已经跑通过大模型 API,但觉得"一个 prompt 一把梭"的方式太粗糙、想玩更工程化架构的开发者;第二类是对 Elasticsearch 比较熟、但想看看它怎么跟 LLM 应用结合的后端工程师。文章里我会把架构思路、核心代码、参数配置、踩坑记录全部放出来,尽量让看完的人能直接照着搭一套 demo 出来。
先说结论:目前这套东西跑稳定之后,日常问答的响应延迟可以控制在 1.5 秒左右(含检索+生成),对业务数据的召回准确率实测比纯 embedding 检索高出 20% 以上,因为加了 rerank 和过滤条件。下面展开讲。
1. 整体架构设计:先别急着写代码,想清楚 AI 代理到底是怎么工作的
1.1 核心概念:AI 代理和普通聊天机器人的区别在哪
很多人以为 AI 代理就是"聊天机器人加个 tool call"。其实不是的。聊天机器人是"你给我一句话,我给你一个回复",是单向的、短连接的。而 AI 代理的特点是:它能拆解目标、规划步骤、调用外部工具、观察结果、修正计划,然后循环直到任务完成。
举个例子。用户说"帮我整理上季度所有客户反馈里的负面评价,并输出一份摘要报告"。普通聊天机器人最多帮你生成一段通用文案,它看不到你的客户反馈数据。AI 代理则会:
- 把任务拆成三步:检索反馈数据、筛选负面内容、生成摘要;
- 调用 Elasticsearch 执行语义检索,过滤出"差评"相关文档;
- 把检索结果拼进 prompt,让大模型总结成报告;
- 检查结果是否符合预期,不满意就换个检索条件再跑一轮。
这个"计划-执行-观察-循环"的能力,就是代理区别于聊天机器人最核心的差异。它实现起来最麻烦的不是写工具函数,而是编排逻辑——你总不能让开发者手写一堆 if-else 去管理状态、上下文、循环次数吧?这就是 Mastra 存在的意义。
1.2 技术选型思考:为什么是 Mastra,而不是从零写编排器
我自己早期搭过一套代理应用,用的方法简单粗暴:写一个 Python 脚本,里面用while循环 +function calling拼上下文,硬是把代理逻辑堆出来。问题是,代码一多,状态管理、错误重试、日志追踪、多代理协作全都变得一团乱。改一个功能,可能要动十几个函数。
Mastra 的核心价值在于,把代理开发中高度重复的部分提炼成了抽象组件:Agent(代理)、Workflow(工作流)、Tool(工具)、RAG(检索增强生成)、Memory(记忆)。它的抽象模型非常适合代理应用——你要做的不是从零搭状态机,而是声明式地描述"代理有哪些工具可用、执行策略是什么、每一步怎么走"。
更重要的是,Mastra 从设计上就是TypeScript 优先的框架。对于做全栈应用或者 Node.js 服务端的团队来说,这意味着 AI 逻辑可以跟业务代码无缝衔接,不用跨界维护 Python 和 Node 两套体系。我自己特别喜欢这一点,因为 ES 那边我本来就要用@elastic/elasticsearch客户端写查询,如果代理层也是 JS/TS,整个链路就不用切换语言了。
1.3 Elasticsearch 在这个项目里扮演的三个角色
很多人把 Elasticsearch 跟"日志系统"划等号,这是对它的刻板印象。在 AI 代理应用里,ES 至少能干三件事:
第一,业务数据检索。这是它的老本行,倒排索引做全文搜索,带过滤、聚合、高亮,实力依旧强劲。第二个,向量语义检索。ES 8.x 之后内置了dense_vector字段类型和kNN查询,可以直接当向量数据库用。第三个,代理记忆存储。代理跟用户对话的历史、中间的推理过程、知识片段,都可以用 ES 存起来——它的索引生命周期管理(ILM)很适合按时间清理缓存型数据。
这三个角色组合起来,意味着你可以在一套基础设施上实现"业务数据搜索 + 语义召回 + 记忆持久化",不用额外引入 Redis、Pinecone、Postgres 来各干各的。对我来说,少一套组件就少一组运维负担。
2. 环境准备与基础配置:把底座搭稳
2.1 安装与版本选型
我这次的开发环境是这样的:Node.js 20.10,npm 10.x,一个自建的 Elasticsearch 8.11 集群(三节点),Mastra 用的是当时最新的 0.1.x 版。为了省事,我本地用的服务版 ES 是 Docker 起的,一键启动。
动手之前先把依赖装好。我用pnpm,因为 TS 项目里它的依赖管理更干净:
# 初始化项目(如果是空目录) pnpm init # 安装 Mastra 核心包 pnpm add @mastra/core # 安装官方 ES 客户端 pnpm add @elastic/elasticsearch # 安装 OpenAI SDK(或其他模型提供商 SDK,按需选) pnpm add openai # devDependencies 装 TypeScript 和构建工具 pnpm add -D typescript @types/node tsx一个容易踩的坑是版本兼容。Mastra 还在快速迭代期,API 变动很频繁,不同小版本之间可能有 breaking change。我的建议是,打开项目的 GitHub Releases 页面,找到跟你的使用日期最接近的一个稳定版,锁定到精确版本号,不要用^0.1.0这种带^的写法,否则哪天pnpm install装到新版本,代码可能就编译不过了。
ES 这边不用装客户端层面太新或太老的东西,@elastic/elasticsearch8.x 系列跟 ES 8.x 集群通信完全兼容。需要注意的只有一点:如果你之前用 7.x 客户端连接过 ES,代码里搜索请求的参数结构有变化(比如body参数被移到顶层了),千万别把老代码直接复制过来。
2.2 初始化 Mastra 应用骨架
Mastra 提供应用生成器,可以快速生成一套标准目录结构。执行:
pnpm dlx create-mastra-app@latest my-ai-agent-app生成出来的目录大约是这样的结构:
my-ai-agent-app/ ├── src/ │ ├── agents/ # 代理定义 │ ├── tools/ # 代理可调用的工具 │ ├── workflows/ # 工作流编排 │ └── index.ts # 应用入口 ├── .env ├── tsconfig.json └── package.json入口文件src/index.ts里,核心任务是注册我们定义的代理。我建议先用官方脚手架把最简单的一个 echo agent 跑通,确认环境链路没问题,再逐步加入 Elasticsearch 的调用。先跑通再扩展,排查问题的范围会小很多。
2.3 ES 索引设计的第一版草案
在写代码之前,索引设计反而最值得花时间。ES 的 mapping 一旦建好,后面改起来极痛苦(虽然可以重新索引,但生产环境代价很大)。我针对这个 AI 代理项目设计了三个索引:
第一个是business_kb,放业务知识库文档,是 RAG 的主要数据源。字段包括标题、正文内容、分类、标签、更新时间,以及一个dense_vector 向量字段,维度根据 embedding 模型来定(下面代码里用的是 1536 维,对应 OpenAI 的 text-embedding-3-large)。
第二个是conversation_memory,存代理和用户的会话记录。字段包含会话 ID、角色、消息内容、创建时间。这个索引的写入频率高、数据增长快,所以开了 ILM 策略:30 天后自动删旧记录,保持索引体积可控。
第三个是agent_logs,存代理每次执行的详细日志——调用了哪个工具、命中哪些文档、模型输出了什么。这个索引主要是调试用,所以我特意把_source关掉了,只存字段,写起来非常快,省磁盘。
这三个索引的 mapping 细节我会在后面的实践章节展示,但先想清楚"数据从哪来、流量长什么样、存活多久",是搭 ES 索引的基本功。
3. 核心实现:让代理学会"搜索"和"思考"
3.1 定义 Agent:给大模型装上一个"编剧大脑"
代理的入口是 Agent 对象。在 Mastra 里,一个 Agent 需要指定三样东西:用的什么模型、有哪些工具、系统 prompt 是什么。我写了一个负责"基于业务知识库回答客户问题"的客服型代理,简化版定义如下:
// src/agents/supportAgent.ts import { Agent } from '@mastra/core'; import { openai } from '@ai-sdk/openai'; import { esSearchTool } from '../tools/esSearchTool'; import { esMemoryTool } from '../tools/esMemoryTool'; export const supportAgent = new Agent({ name: 'support-agent', model: openai('gpt-4o-mini'), instructions: ` 你是一名专业的客服助手,负责基于知识库回答客户问题。 规则: 1. 只使用工具返回的数据回答问题,不要编造内容。 2. 如果检索结果不足,明确告诉用户"暂时没有找到相关信息"。 3. 回答尽量简洁、有条理,分点输出。 `, tools: { esSearch: esSearchTool, memory: esMemoryTool, }, });这一步看起来简单,但背后的关键决策是把 ES 搜索逻辑封装成工具(Tool)而不是写死在代理代码里。这样做的好处是:模型可以在运行时自主决定"需不需要搜索、搜什么关键词、过滤条件是什么",而不是由我们预先编写固定查询逻辑。不同用户问不同问题时,模型会动态生成查询参数——这正是"代理能力"与"普通检索问答"的分水岭。
3.2 编写 ES 搜索工具:模型接口和 ES 查询之间的一层桥
Tool 是 Mastra 中最常用的抽象,本质上是"给模型看一个带描述的 JSON schema,当模型决定调用它时,框架去执行对应的函数"。我实现的esSearchTool职责是:接收模型的检索意图,转成 ES 查询,返回命中结果。
型如下:
// src/tools/esSearchTool.ts import { Tool } from '@mastra/core'; import { z } from 'zod'; import { client } from '../lib/esClient'; // 共享的 ES 客户端实例 export const esSearchTool = new Tool({ name: 'ES 知识库搜索', description: '在业务知识库中搜索与用户问题相关的文档,返回匹配的正文片段。', args: z.object({ query: z.string().describe('搜索关键词或自然语言问题'), topK: z.number().default(5).describe('返回的文档数量'), category: z.string().optional().describe('按分类过滤'), }), execute: async ({ query, topK, category }) => { const mustClause: any[] = []; if (category) { mustClause.push({ term: { category } }); } const result = await client.search({ index: 'business_kb', size: topK, query: { bool: { must: [ { multi_match: { query, fields: ['title^3', 'content'], fuzziness: 'AUTO', }, }, ], filter: mustClause, }, }, highlight: { fields: { content: { fragment_size: 200, number_of_fragments: 1 } }, }, }); return (result.hits.hits ?? []).map((hit: any) => ({ title: hit._source.title, content: hit.highlight?.content?.[0] ?? hit._source.content, score: hit._score, })); }, });注意这里我用了multi_match+fuzziness: 'AUTO'。为什么不用向量检索?因为导出到底用全文搜索还是向量检索,取决于用户问题的类型。像"退款的流程是什么"这种明确的业务问题,全文检索匹配效果更好,速度也更快(毫秒级)。而"用户觉得产品难用该怎么办"这种语义含糊的问题,才更适合走向量。最理想的设计是让代理自己判断该用哪种检索——这就需要在 prompt 里写明两种工具的区别。实际做的时候,我倾向于默认走 hybrid(混合检索),后面会讲怎么合并分数。
3.3 接入向量检索:让代理具备"语义理解"能力
只靠关键词检索,代理的理解能力是有限的。比如用户说"我的账号登不进去了",如果知识库里有一篇文章标题是"身份认证常见故障排查",关键词匹配永远匹配不上这条,因为表面词汇完全不重叠。向量检索能把"登不进去"和"身份认证故障"映射到向量空间的相近位置。
首先在索引 mapping 里加上向量字段,ES 8.11 支持的写法如下:
{ "mappings": { "properties": { "title": { "type": "text" }, "content": { "type": "text" }, "category": { "type": "keyword" }, "embedding": { "type": "dense_vector", "dims": 1536, "similarity": "cosine", "index": true, "similarity_metric": "cosine" } } } }写入文档时,需要先调用 embedding 模型生成向量,再连同原文一起写入:
// src/lib/embedding.ts import OpenAI from 'openai'; const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY }); export async function generateEmbedding(text: string): Promise<number[]> { const resp = await openai.embeddings.create({ model: 'text-embedding-3-large', input: text, }); return resp.data[0].embedding; }批量写入时建议用 ES 的 bulk API 一次性导入,比逐条 index 快几十倍。我有一篇文档,开始逐条写 3000 条数据用了 20 多分钟,换成 bulk 分片写入后,1 分钟不到就跑完了。后面同步增量数据时,我也一直用 bulk。
在代理工具里加一个向量检索分支:
const embedding = await generateEmbedding(query); const knnQuery = { knn: { field: 'embedding', query_vector: embedding, k: topK, num_candidates: 100, }, };3.4 混合检索:全文和向量结果合并,避免"各说各话"
纯向量检索的另一个问题是:它对专有名词、编号、精确型号不太敏感。比如用户问"XK-2200 设备的固件版本是多少",这种带着精确型号的问题,关键词匹配(需要精确匹配 XK-2200)明显更可靠,而向量检索可能把语义近似的其他设备文档也捞出来,反而干扰模型判断。
所以我最终采用的是混合检索策略。ES 提供了一个非常实用的特性:RRF(Reciprocal Rank Fusion,倒数排名融合),可以简单理解为把多路召回结果按排序位置加权合并,而不需要关心各路返回的原始分数量纲不一致的问题。Mastra 工具里的查询可以写成:
const result = await client.search({ index: 'business_kb', size: topK, query: { bool: { must: [ { multi_match: { query, fields: ['title^3', 'content'], fuzziness: 'AUTO', }, }, ], filter: mustClause, }, }, knn: { field: 'embedding', query_vector: embedding, k: topK, num_candidates: 100, }, rank: { rrf: { window_size: 50 } }, });注意,knn和query同时出现在一个search请求里,再配rank.rrf,这就是 ES 原生的混合检索写法。实际运行中,RRF 的window_size默认是 50,我测试时发现把 window_size 调大可以让长尾文档更容易浮上来,调小则头部优势更明显。一般知识库问答场景,50 的效果已经不错了,如果想只盯着 top5 的相关性,可以调到 20 左右。
4. 为代理建立"记忆":Elasticsearch 存储会话与上下文
4.1 为什么需要记忆:没有上下文的代理就是"金鱼"
现在的对话式 AI 产品,用户根本不会再容忍"你刚才说什么我忘了"这种体验。如果你做的是一个客服代理、私人助手代理,它必须记住前文。
Mastra 自带 Memory 模块,默认支持多种存储后端。我第一反应是直接用默认存储,但想了一下,既然 ES 已经在这个项目里了,不如就让 ES 充当记忆存储,省一个 Redis 或 Postgres。同时,ES 的记忆天然能做到"记忆的全文检索"——比如你让代理回忆"上周用户问过哪些关于退款的问题",这不只是查聊天记录,而是可以做语义检索召回相关内容,普通 KV 存储做不到这一点。
4.2 实现方案:以会话 ID 为维度存取历史消息
我在 ES 里建了conversation_memory索引,mapping 大致如下:
{ "mappings": { "properties": { "sessionId": { "type": "keyword" }, "role": { "type": "keyword" }, "content": { "type": "text" }, "ts": { "type": "date" } } } }每次代理响应前,先从 ES 拉取最近 N 条历史(按ts倒序),拼到 system prompt 里,让模型"记得"上下文。响应完成后,再把新的 user/assistant 消息写回 ES。伪代码如下:
async function getMemoryContext(sessionId: string, limit = 10) { const result = await client.search({ index: 'conversation_memory', size: limit, query: { term: { sessionId } }, sort: [{ ts: 'desc' }], }); return result.hits.hits.map((hit: any) => `${hit._source.role}: ${hit._source.content}`).reverse(); } async function saveMemory(sessionId: string, role: string, content: string) { await client.index({ index: 'conversation_memory', document: { sessionId, role, content, ts: new Date() }, }); }这个实现虽然简单,却有一个关键好处:记忆是跨会话共享的。用户 ID 可以作为sessionId的前缀,比如uid-12345,这样即使新建一个会话,代理也能靠uid-*前缀拉取这个用户的长期记忆,做一个简单的"画像"出来。如果只想按对话隔离,就用随机 uuid 作为 sessionId 即可。
4.3 记忆的剪枝与总结:别让上下文撑爆 token
ES 存记忆没有成本焦虑,但 token 有。如果用户连续聊了 100 轮,总把全部历史塞进 prompt,再大的 context window 也会爆。最好的做法是分层记忆:
- 短期记忆:最近 10 轮对话原文。
- 长期记忆:每隔一定轮次,让模型把前面的对话汇总成摘要,只存摘要文本。
Mastra 的 Memory 抽象本身支持这类操作,但我自己实现更可控一点。在写入新消息后,检查当前 session 的累计消息数,如果超过阈值,就对旧消息做一次 summarize,把摘要存为一条特殊 role 的记录,旧的逐条记录可以存档或删除。这是代理产品上线必做的优化,不然跑一阵子就会出现"回答越来越慢、越来越昂贵"的尴尬。
5. 代理与 ES 联调的完整实操流程
5.1 数据准备:把业务文档切片喂给 ES
知识库的数据不可能整篇整篇塞进 ES,因为检索时按整篇返回+"喂给 LLM",内容太长会让相关性和 token 开销双重失控。我的做法是:先切片,再向量化,再批量写入。
切片策略我试过几种,最后用的是"段落+标题感知"的切法。对于 Markdown 或富文本文档,优先按标题层级切块;对于纯文本,按 500 字左右切块且带 50 字的重叠,保证语义连贯。以一段简单文档举例,我写了个函数模拟这个过程:
function splitIntoChunks(text: string, maxLen = 500, overlap = 50): string[] { const sentences = text.match(/[^。!?\n]+[。!?\n]?/g) ?? []; const chunks: string[] = []; let buf = ''; for (const sentence of sentences) { if (buf.length + sentence.length > maxLen && buf.length > 0) { chunks.push(buf.trim()); buf = (buf.slice(-overlap) || '') + sentence; } else { buf += sentence; } } if (buf.trim()) chunks.push(buf.trim()); return chunks; }切片之后的每个 chunk,都作为一条独立文档写入 ES,content字段存正文,embedding字段存向量。检索返回之后,拼到 prompt 里的也是这块 chunk 的内容。
5.2 首次启动链路测试:一口一口验证,别直接跑完整对话
我见过太多人搭完框架,上来就问一个综合问题,结果报错了都不知道是哪一环出了问题。我自己的联调顺序是这样的:
第一步,验证 ES 数据层。先在 Kibana 或curl里直接查business_kb索引,确认文档数和向量字段都已写入。
第二步,单独测试搜索工具。不启动 Agent,直接写一个临时脚本调用esSearchTool.execute(),传一个 query,看返回的文档是否符合预期。这一步能在几分钟内发现:检索条件是不是太宽/太窄、向量维度是否匹配。
第三步,在 Agent 中只挂 esSearchTool,不带 memory。跑几轮对话,看模型是否能正确触发工具的调用,返回的最终答案有没有依据检索内容。
第四步,再加上 memory。此时才测试长对话的连贯性。
第五步,压测与性能优化。这时才关注响应延迟。
每步通过后再进下一步,出问题时只用在上一步找原因。链路五个环节,任何一步出问题,日志和定位范围都很清晰。
5.3 调试技巧:把代理的每一步"摊开"来看
代理应用的调试比普通后端难,因为它不是一次请求一个响应,而是多轮工具调用。我强烈建议打开 Mastra 等框架的agent telemetry(遥测/追踪)或自写日志,记录:
- 模型收到的完整 system prompt 长什么样;
- 模型发出过几次 tool call 请求,参数是什么;
- 每次工具返回的结果是什么;
- 模型最终生成了什么回复。
我在代码里加了一个简单的 logger,每次工具调用时打印相关信息,格式大致是:
[TOOL_CALL] esSearch with query="退款流程", topK=5, category="help" [TOOL_RESULT] hit1: 标题, score=3.42, content=... [LLM_OUTPUT] 根据资料,退款流程分为...有了这一步,"模型没有按预期调用工具"还是"工具返回内容质量太差",一眼就能区分开。很多时候,代理行为不对不是因为代码 bug,而是prompt 没有表达清楚——修改 instructions 然后重跑测试,是我迭代最频繁的动作。
6. 进阶:工作流编排——把多步任务串成流水线
6.1 什么时候需要 Workflow 而不是单纯 Agent + Tool
Agent 自己会规划步骤,但那是一个"自由发挥"的过程,模型可能今天这么走、明天那么走,这对于要固定执行流程、控制每一步质量的场景来说,是致命的。比如,在知识库问答之前必须先执行召回 → 要让大模型对召回的多个片段做独立评分 → 分数低的直接丢弃 → 最后才喂给最终模型。这种流程如果全靠 Agent 自由发挥,结果会非常不稳定。
所以 Mastra 里还有一个抽象叫Workflow。简单理解:Workflow 是确定性的流水线,步骤和顺序由开发者决定;Agent 是自由式规划,步骤由模型决定。两者可以嵌套使用。
6.2 搭建一个"检索→重排→生成"的 RAG 工作流
我实现了一个固定的 RAG 工作流,作用是把"搜索增强生成"的过程标准化。
// src/workflows/ragWorkflow.ts import { Workflow } from '@mastra/core'; import { esSearchTool } from '../tools/esSearchTool'; import { rerank } from '../lib/rerank'; import { openai } from '@ai-sdk/openai'; import { generateText } from 'ai'; const ragWorkflow = new Workflow({ name: 'rag-workflow', steps: [ { name: 'retrieve', execute: async ({ context }) => { const docs = await esSearchTool.execute({ query: context.query, topK: 20 }); return { docs }; }, }, { name: 'rerank', execute: async ({ context }) => { const docs = context.docs; const reranked = await rerank(context.query, docs); return { topDocs: reranked.slice(0, 5) }; }, }, { name: 'generate', execute: async ({ context }) => { const { text } = await generateText({ model: openai('gpt-4o-mini'), system: '基于资料回答:', prompt: `问题: ${context.query}\n资料:\n${context.topDocs.map(d => d.content).join('\n---\n')}`, }); return { answer: text }; }, }, ], });这个 Workflow 的核心在于第二步rerank。我手头没有专门的 rerank 模型,就先用 GPT 充当 reranker:把 retrieve 阶段召回的 20 条文档批量塞给模型,让它按问题相关性打分,取 top5 的文档去做后面的生成。虽然调用成本略高,但效果立竿见影——之前模型经常被无关文档带偏,重排后回答准确率和引用正确率都上了一个台阶。
6.3 工作流里的条件分支:当检索结果不足时怎么办
还有一个常见场景:知识库里没有答案时,代理不能硬编。我在 workflow 里加了一个条件判断步骤:如果 top1 文档的相关性分数低于阈值(比如全文检索_score低于 0.5 或者 rerank 打分太差),工作流直接进入"查不到"分支,生成类似"抱歉,知识库暂时没有覆盖到这个话题,建议联系人工客服"的回复,而不是强行生成一个可能编造事实的答案。这对客服场景尤其重要——宁可承认不知道,也不能一本正经地胡说。
7. 常见问题与排查技巧实录
7.1 问题一:向量维度不一致导致索引无法写入或检索报错
ES 对 dense_vector 的维度要求极严。如果建 mapping 时写死了dims:1536,但写入的 embedding 是 3072 维(比如换了个模型没同步),写入必然 400 报错。最坑的是,这个错误有时候不是立即抛在 index 请求上,而是出现在后续的kNN检索时。排查思路很简单:先GET business_kb/_mapping看实际维度,再检查你的 embedding 函数到底调用哪个模型、输入了什么文本。我自己就踩过一次,上线前把 embedding 模型从text-embedding-3-small(1536 维)换成了text-embedding-3-large(3072 维),忘记重建索引,结果线上检索直接挂了。解决办法只有一个:重建索引,没有捷径。
7.2 问题二:混合检索 RRF 后相关性和预期不符
如果你用了 RRF 混合检索,发现模型引用的资料偶尔驴唇不对马嘴,大概率是全文检索的头部文档压过了向量召回的文档,但全文那篇其实只匹配了零碎关键词。RRF 不看分数,只看名次,这意味着即使全文检索召回的文档非常靠前但质量不行,它仍然会被算进最终结果。解决办法三个方向选一:调低全文检索的字段权重;加minimum_should_match要求至少匹配一定比例的分词;或把 RRF 的结果再走一次 rerank 步骤,只取高质量片段。
7.3 问题三:代理就是死活不调用工具
最让人抓狂的调试场景是:日志里模型一个 tool call 都没有,直接对着空气回答。99% 的情况是instructions 写得不够明确。我踩过一次,当时写的是"你可以搜索知识库",模型根本不鸟你。后来改成"如果你不确定答案,必须先调用 esSearch 工具搜索知识库;只要用户问题是业务相关,一律先搜索再回答",行为立刻正常了。要记住,大模型对模糊指令的诠释完全不可控,工具的触发条件必须写清楚"must"或"should",最好把优先级也讲明白。
7.4 问题四:响应延迟高,问答超过 5 秒
代理应用慢,通常慢在四个环节。第一个是 embedding 生成,第一个,第一个嵌入调用要几百毫秒;第二个是 ES 检索,全文和向量两个查询叠加后,大索引也可能要几百毫秒;第三个是 rerank 步骤调 LLM,是最耗时的一环;第四个是最终生成,tokens 越多越慢。优化思路:
- 把 embedding 结果缓存下来,同一个 query 短时间重复出现直接命中缓存;
- ES 索引分片和 filter 条件优化;
- rerank 用轻量小模型,或者只在 top20 中取一部分做重排,减少被评分的文档数;
- 如果预算允许,把生成模型从 gpt-4o 换成 gpt-4o-mini 或更快的模型,延迟能降一半以上。
实测调优后,完整链路从 6 秒左右降到了 2 秒内,用户体感已经比较顺畅了。
7.5 问题五:ES 大规模日志与知识库混用带来的性能干扰
如果你的 ES 集群上还跑着别的日志索引,知识库检索的性能可能会被拖累。我遇到过:某个时段检索特别慢,排查发现是其他团队的大规模日志写入把集群的 IO 打满了。解决办法是物理隔离或逻辑隔离。物理隔离最简单:给知识库单独一组节点或者单独一个集群。逻辑隔离就是调优先级、限制分片恢复速度、设置索引级别的index.search.idle.after等参数。如果条件有限,至少把知识库索引的refresh_interval调成30s(默认 1s),减少大量写入对检索的影响。
8. 工具选型与方案对比:为什么这套组合合我的胃口
8.1 Mastra vs. 自研编排 vs. 其他 AI 框架
最后聊一下方案对比,因为这个项目做下来,我最大的体会是选型比写代码影响深远得多。自研编排听起来很酷,但状态管理、失败重试、多步循环、日志追踪这些模块,任何一个做不完善,线上都会出大问题。而成熟的框架如 Mastra 最大的价值是把工程化细节提前替你处理好了,让你能集中精力做业务逻辑和 prompt 调优。
当然 Mastra 也有缺点。它迭代太快,API 变动频繁;生态比 LangChain 小不少,社群资料少;碰到冷门需求只能自己看源码。我的态度是:新项目,如果团队有 TS 基础且愿意拥抱变化,Mastra 值得一试;如果是超大团队求稳,可以先观望或选更成熟的方案。但如果你是个人开发者搭 demo 或做内部工具,Mastra 绝对是最快出活的选项。
8.2 Elasticsearch vs. 专用向量数据库
第二个方案对比是 ES 和专用向量数据库(比如 Pinecone、Milvus、Qdrant)怎么选。如果只做向量搜索,专用向量库在某些场景下性能和运维成本确实更好。但在 RAG 场景里,几乎总是需要"关键词 + 元数据过滤 + 向量"的组合,ES 的强项是这一套它全都自带了。更别提大量业务中间件已经把数据存在 ES,与其多引一套存储,不如让 ES 一鱼多吃。缺点是:ES 的内存和磁盘开销比专用向量库高,如果数据量极大且只做纯向量召回,专用方案会有优势。所以结论是,混合检索、复杂过滤、业务数据耦合场景选 ES;纯向量海量召回且预算敏感时,再考虑专用方案。
8.3 需要避开的"模型能力陷阱"
最后想提醒一件事:不要默认大模型"什么都会"。很多开发者在 prompt 里要求模型"判断用户情感并自动决定是否升级工单",乍看很智能,但模型的情感判断和路由决策是黑盒,非常容易出现误判。更稳妥的做法是:能用规则和传统程序确定的事,就别交给模型自由发挥。比如上面的案例,完全可以先用 ESsentiment分析插件或者简单的关键词黑名单做第一层判断,命中后再让模型介入,或者直接把命中结果作为条件分支写进 workflow。代理能力强,不代表你应该把所有事情都丢给它。
结语(一点个人体会)
这套项目做下来,我最大的感受是:AI 代理应用的开发难点,不在于"调用大模型"本身,而在于把大模型嵌进一套可靠的工程体系里。这套体系包括了检索、记忆、流程编排、日志、评估这些"不那么炫酷"的部分。Mastra 帮我解决了一半的工程化问题,Elasticsearch 帮我解决了另一半的数据问题,两者结合才把"具备代理能力的 AI 应用"这个目标变成了可持续迭代的代码库。
我觉得 Mastra 和 ES 这套组合,短期来看有一点学习成本,但只要上手,后面扩展各种智能应用场景都非常顺手。无论是给产品加一个客服代理,还是做一个能检索内部文档的团队助手,甚至做自动化的实验数据处理,这个底座都能复用。如果非要给后来者一句话的建议:先把你最想验证的一个场景做到端到端跑通,再考虑铺开功能;代理应用迭代最快的方式,永远是小步快跑,而不是憋大招。