两个娃的奶爸深夜折腾FastGPT,用v0搞了个AI应用还顺手做了性能优化
凌晨两点半,大宝刚退烧睡着,二宝还在哼唧。我轻手轻脚关上儿童房的门,溜回书房,灌了半罐冰可乐,打开电脑。白天在公司被需求追着跑,晚上才是真正属于自己的时间。最近边准备跳槽边刷算法题,顺便还在啃Rust——那所有权机制简直比哄两个孩子睡觉还让人头秃。不过今天不聊Rust,聊聊最近搞技术探索的一些实践心得,顺便分享一下我用FastGPT和v0搭AI应用时做性能优化的踩坑记录。
为啥要折腾这些
说来话长。去年底公司搞了个内部创新项目,领导说要做个"AI赋能"的内部工具,说白了就是蹭AI热度。产品经理一拍脑袋:"我们要做一个智能知识库问答系统,员工可以直接问公司制度、技术文档,AI来回答!"
我一听,这不就是RAG(检索增强生成)嘛。当时市面上开源方案不少,LangChain、Dify、FastGPT都能搞。我花了两个周末调研了一圈,最后选了FastGPT。为啥?一是它开箱即用,部署简单;二是它的知识库管理做得比较直观,对非技术人员也友好;三是社区活跃度还行,遇到问题能搜到答案。
但说实话,刚开始用的时候体验并不好。第一次打开FastGPT的后台,加载要等将近十秒,知识库列表页更是慢得让人想砸键盘。我心里嘀咕:这玩意儿要是给全公司用,第一天就得被投诉到IT部门去。
于是,性能优化这事儿就这么被提上了日程。
第一刀:FastGPT的前端加载优化
先说FastGPT本身。它前端用的Next.js,整体架构没毛病,但默认配置下确实有不少可以抠的地方。
1. 首屏加载慢的元凶
我用Chrome DevTools的Performance面板跑了一下Lighthouse,首屏FCP(First Contentful Paint)居然要4.2秒,LCP(Largest Contentful Paint)更是飙到了6.8秒。这数据放出去,前端同事看了怕是要笑出声。
排查下来,问题主要出在三个地方:
- JavaScript Bundle太大:主bundle超过800KB,还没算各种chunk
- 字体文件没做子集化:一个中文字体文件直接干到8MB
- API请求串行执行:首页加载时,知识库列表、用户信息、系统配置三个接口是串行发的
2. 动手优化
字体问题最好解决。FastGPT默认引入了一个完整的中文字体,我用fonttools做了子集化,只保留常用汉字和ASCII字符,体积直接从8MB干到了120KB。
# 用pyftsubset做字体子集化
pyftsubset SourceHanSansSC-Regular.otf \
--text-file=common-chars.txt \
--output-file=SourceHanSansSC-subset.woff2 \
--flavor=woff2
common-chars.txt里我放了GB2312常用汉字加上一些特殊符号,基本覆盖日常使用场景。
JS Bundle的问题,我调整了Next.js的配置,开启了modularizeImports,把一些大依赖按需加载:
// next.config.js
const nextConfig = {
modularizeImports: {
'lodash': {
transform: 'lodash/{{member}}',
},
'@chakra-ui/react': {
transform: '@chakra-ui/react/dist/esm/components/{{member}}',
},
},
experimental: {
optimizePackageImports: ['lucide-react', 'dayjs'],
},
}
这一波操作下来,主bundle从800KB降到了340KB,首屏FCP直接干到了1.8秒。
API串行请求的问题,我改了前端的请求逻辑,用Promise.all把三个接口并行发出去:
// 优化前:串行请求
async function loadHomePage() {
const user = await fetchUserInfo();
const kbList = await fetchKBList();
const config = await fetchSystemConfig();
return { user, kbList, config };
}
// 优化后:并行请求
async function loadHomePage() {
const [user, kbList, config] = await Promise.all([
fetchUserInfo(),
fetchKBList(),
fetchSystemConfig(),
]);
return { user, kbList, config };
}
就这么一个改动,首页数据加载时间从2.1秒降到了0.8秒。有时候性能优化就是这么朴实无华,不需要什么高大上的技术方案,把基本的东西做对就行。
第二刀:知识库检索的性能瓶颈
前端优化完,我又把目光投向了后端。FastGPT的知识库检索用的是向量数据库,默认配置下用的是Milvus。我们数据量不大,也就几万条文档,但检索响应时间居然要1.5秒以上。
1. 向量检索慢在哪
我加了几个耗时埋点,发现主要慢在两个环节:
| 环节 | 优化前耗时 | 优化后耗时 |
|---|---|---|
| 文本向量化(Embedding) | 820ms | 210ms |
| 向量检索 + 排序 | 480ms | 180ms |
| LLM生成回答 | 2100ms | 2100ms(没动) |
Embedding环节慢,是因为每次请求都实时调用OpenAI的API做向量化。这玩意儿网络延迟加上模型推理,800ms都算快的了。
我的优化思路是加一层缓存。相同或相似的文本不需要重复计算向量。我用Redis做了一个简单的缓存层:
import { createHash } from 'crypto';
import Redis from 'ioredis';
const redis = new Redis(process.env.REDIS_URL);
async function getEmbeddingWithCache(text: string): Promise<number[]> {
// 用文本hash作为缓存key
const textHash = createHash('md5').update(text).digest('hex');
const cacheKey = `embedding:${textHash}`;
// 先查缓存
const cached = await redis.get(cacheKey);
if (cached) {
return JSON.parse(cached);
}
// 缓存没命中,调用API
const embedding = await openai.embeddings.create({
model: 'text-embedding-3-small',
input: text,
});
const vector = embedding.data[0].embedding;
// 写入缓存,过期时间7天
await redis.setex(cacheKey, 7 * 24 * 3600, JSON.stringify(vector));
return vector;
}
这个改动上线后,重复问题的Embedding环节直接从820ms降到了5ms(Redis读取耗时)。即使是新问题,因为很多时候用户问的内容跟历史问题有重合,缓存命中率也能到40%左右。
向量检索那块,我把Milvus的索引类型从默认的IVF_FLAT换成了HNSW,同时调整了ef参数:
# Milvus collection配置优化
collection_config:
index_type: HNSW
metric_type: COSINE
params:
M: 16
efConstruction: 200
ef: 128 # 检索时的ef参数,越大越精确但越慢
HNSW在中小数据量下的表现比IVF_FLAT好不少,检索时间从480ms降到了180ms,而且召回率基本没掉。
第三刀:用v0快速搭建AI应用前端
说到这儿,得提一嘴v0。这玩意儿是Vercel出的AI生成UI的工具,最近火得不行。我一开始是抱着玩玩的心态用的,没想到真香。
1. v0是什么
简单说,你用自然语言描述你想要的界面,v0就能给你生成React组件代码,基于shadcn/ui和Tailwind CSS。生成的代码质量还不错,至少比我自己从零写要好看。
2. 我拿v0干了啥
我给FastGPT做了一个自定义的对话界面。原生的对话界面功能是全,但样式嘛……只能说"能用"。我想做一个更现代、更适合内部使用的对话界面,但又不想花太多时间在前端样式上——毕竟我晚上时间宝贵,哄完两个孩子睡觉已经九点半了,能用来写代码的时间就那么两三个小时。
用v0,我大概花了四十分钟就搞出了一个像样的对话界面。prompt大概是这么写的:
Design a modern chat interface for an AI knowledge base assistant.
Features needed:
- Message bubbles with markdown rendering support
- Code syntax highlighting in messages
- A sidebar showing conversation history
- A reference panel showing source documents
- Dark mode support
- Responsive layout for mobile
Style: clean, minimal, similar to ChatGPT but with a blue accent color
v0生成的代码我稍微改改就能用。最让我惊喜的是它对shadcn/ui组件的运用很熟练,生成的代码结构清晰,不是那种一团乱麻的屎山。
3. v0生成代码的性能优化
不过v0生成的代码也不是完美的,有些地方还是得手动优化。比如它生成的消息列表,用的是普通的map渲染,消息多了之后滚动会卡顿。我给它加上了虚拟滚动:
import { Virtuoso } from 'react-virtuoso';
function MessageList({ messages }: { messages: Message[] }) {
return (
<Virtuoso
data={messages}
followOutput="smooth"
itemContent={(index, message) => (
<MessageBubble
key={message.id}
message={message}
isStreaming={index === messages.length - 1 && message.streaming}
/>
)}
components={{
// 自定义滚动条样式
Scrollbar: (props) => (
<div {...props} className="w-1.5 bg-gray-200 dark:bg-gray-700 rounded-full" />
),
}}
/>
);
}
还有一个坑:v0生成的Markdown渲染组件,每次消息更新都会重新解析整个Markdown文本。对于流式输出的场景,这会导致严重的性能问题——每收到一个token就要重新渲染一次,CPU直接起飞。
我的解决方案是做了一个增量渲染的优化:
import { useMemo, useRef } from 'react';
import ReactMarkdown from 'react-markdown';
function StreamingMarkdown({ content, isStreaming }: { content: string; isStreaming: boolean }) {
const lastRenderedLength = useRef(0);
// 流式输出时,只对新增部分做高亮处理
// 已渲染的部分不再重复解析
const { stablePart, streamingPart } = useMemo(() => {
if (!isStreaming) {
return { stablePart: content, streamingPart: '' };
}
// 找到最后一个完整的段落/代码块
const lastBreakPoint = findLastCompleteBlock(content);
return {
stablePart: content.slice(0, lastBreakPoint),
streamingPart: content.slice(lastBreakPoint),
};
}, [content, isStreaming]);
return (
<div className="markdown-body">
<ReactMarkdown>{stablePart}</ReactMarkdown>
{streamingPart && (
<span className="streaming-cursor">
<ReactMarkdown>{streamingPart}</ReactMarkdown>
</span>
)}
</div>
);
}
function findLastCompleteBlock(text: string): number {
// 找最后一个完整的代码块或段落
const lastCodeBlockEnd = text.lastIndexOf('```');
const lastParagraphBreak = text.lastIndexOf('\n\n');
if (lastCodeBlockEnd > lastParagraphBreak) {
// 如果代码块没闭合,回到代码块之前
const lastCodeBlockStart = text.lastIndexOf('```', lastCodeBlockEnd - 1);
if (lastCodeBlockStart !== -1 && text.slice(lastCodeBlockEnd).split('```').length % 2 === 0) {
return lastCodeBlockStart;
}
return lastCodeBlockEnd + 3;
}
return lastParagraphBreak > 0 ? lastParagraphBreak : text.length;
}
这个优化让流式渲染时的CPU占用从35%降到了8%左右,滚动也丝滑多了。
综合效果与数据说话
折腾了大概两周(都是利用晚上和周末的时间,白天还得正常搬砖),整体效果还是不错的。我把优化前后的关键指标做了个对比:
| 指标 | 优化前 | 优化后 | 提升幅度 |
|---|---|---|---|
| 首屏FCP | 4.2s | 1.8s | 57% |
| 首屏LCP | 6.8s | 2.9s | 57% |
| 知识库检索响应 | 1.5s | 0.4s | 73% |
| 对话首字响应 | 2.8s | 1.1s | 61% |
| 流式渲染CPU占用 | 35% | 8% | 77% |
| Lighthouse总分 | 42 | 89 | 112% |
这些数据汇报给领导的时候,他还是很满意的。虽然他觉得"AI嘛,快一点慢一点用户也感知不到",但我觉得做技术的人还是要有追求的。你说是不是?
一些碎碎念
写到这里,已经凌晨四点了。二宝刚才又醒了一次,老婆起来哄的,我趁这会儿赶紧把文章收尾。
说实话,做技术探索这事儿,在公司里有时候挺尴尬的。你花时间去优化性能,业务价值短期内看不出来,OKR上也不好写。但不做吧,自己心里又过不去那道坎——明明可以更快,为什么要让用户等?
我媳妇总说我,"两个孩子你都不管,天天晚上对着电脑,到底在搞什么名堂。"我能怎么说呢,说我在给公司的AI应用做性能优化?她只会回我一句:"优化完了能涨工资吗?"
嗯……不能。但最近在准备跳槽,这些实战经验写进简历里,面试的时候能聊,也算是一种积累吧。而且说实话,折腾FastGPT和v0的过程中,确实学到了不少东西。比如向量数据库的索引策略、Next.js的性能优化技巧、流式渲染的处理方式,这些都是实打实的干货。
另外最近还在啃Rust,越看越觉得这语言有意思。所有权、生命周期、模式匹配……每一块都让人头疼,但又让人欲罢不能。等把FastGPT这边稳定下来,我打算用Rust重写一下知识库检索的核心模块,看看能不能再榨出点性能来。当然,这可能又是一个大坑,但程序员嘛,不就是在不断踩坑和填坑中成长起来的吗。
对了,如果你也在搞AI应用开发,有几个小建议分享:
- 别迷信开箱即用。FastGPT、Dify这些工具确实方便,但默认配置往往不是最优的,一定要根据自己的场景做调优
- 缓存是性能优化的万金油。能缓存的地方尽量缓存,但要注意缓存一致性和过期策略
- 前端体验很重要。AI应用的响应速度直接影响用户感知,流式输出、骨架屏、虚拟滚动这些手段该用就用
- 善用AI工具提效。v0这类工具可以帮你快速搭建原型,把省下来的时间花在核心逻辑上
- 数据说话。优化完了一定要有对比数据,不然你说了半天,别人也不知道你到底做了啥
好了,不扯了,天都快亮了。趁两个小祖宗还没醒,我得赶紧去洗个澡换身衣服,八点还得打卡上班呢。中年程序员的日常就是这样,白天给公司打工,晚上给自己"打工",痛并快乐着。
如果你也有类似的技术探索经历,欢迎在评论区交流。说不定咱们还能聊聊Rust——虽然我现在还在跟借用检查器斗智斗勇,编译报错多到怀疑人生。
就这样吧,晚安(或者说早安?)。
本文提到的所有代码均经过脱敏处理,如有雷同纯属巧合。FastGPT和v0都是优秀的开源/AI产品,文中提到的优化思路仅供参考,具体方案请根据自身场景调整。


评论 0