做 AI 浏览器别急着接大模型,先把这几处成本坑填上|源码示例
AI浏览器 如何降低成本|附源码
在大模型应用快速落地的今天,“AI浏览器”已经成为一个非常典型的产品形态:用户打开网页后,可以让 AI 总结页面、翻译内容、提取重点、生成待办、对网页内容进行问答,甚至自动填写表单、辅助搜索资料。
但真正做过 AI 浏览器的人会发现:成本很容易失控。
因为浏览器场景天然有几个特点:
- 网页内容很长;
- 用户操作频繁;
- 同一个页面可能被反复分析;
- 多标签页、多任务并发;
- 用户希望实时响应;
- AI 请求一多,Token 成本和延迟都会上升。
所以,AI浏览器不是简单地“把网页内容丢给大模型”就可以了。想要长期稳定运行,必须从架构层面设计成本优化方案。
本文会从产品设计、技术架构、Token 优化、缓存策略、模型选择、流式响应、前后端实现等角度,系统讲解如何降低 AI 浏览器的成本,并附上一个简化版源码示例,帮助你快速理解实现思路。
一、AI浏览器为什么容易烧钱?
AI浏览器的核心能力通常包括:
- 网页摘要;
- 页面问答;
- 网页翻译;
- 关键词提取;
- 文章改写;
- 自动搜索;
- 自动阅读多个网页;
- 自动执行浏览器操作;
- 长网页内容理解;
- 多轮上下文对话。
这些能力背后都依赖大模型推理,而大模型计费通常和 Token 数量相关。
简单来说:
成本 ≈ 输入 Token 成本 + 输出 Token 成本 + 请求次数成本 + 向量检索成本 + 服务器成本
如果用户打开一个网页,这个网页有 2 万字,你每次提问都把全文传给大模型,那么成本会非常高。
例如:
网页正文:20,000 中文字符
转成 Token:约 10,000 ~ 15,000 tokens
用户问 10 个问题
如果每次都传全文:约 100,000 ~ 150,000 input tokens
如果用户很多,这个成本会迅速放大。
因此,AI浏览器降本的第一个原则是:
不要每次都把完整网页内容传给大模型。
二、AI浏览器降本的核心思路
想降低成本,可以从以下几个方向入手:
| 优化方向 | 说明 |
|---|---|
| 内容清洗 | 去掉广告、导航、脚本、无关文本 |
| 文本切片 | 将长网页拆成多个片段 |
| 向量检索 | 用户提问时只取相关片段 |
| 摘要缓存 | 同一个网页只总结一次 |
| 问答缓存 | 相同问题直接返回缓存 |
| 分级模型 | 简单任务用便宜模型,复杂任务用强模型 |
| 流式输出 | 提升体验,降低用户重复点击 |
| 限制上下文 | 控制最大输入 Token |
| 本地预处理 | 在浏览器端完成内容提取 |
| 请求合并 | 避免重复请求模型 |
| 增量处理 | 只处理变化部分 |
| 用户额度 | 防止恶意或无意识高频调用 |
其中最关键的三个技术是:
- 网页正文提取
- RAG 检索增强生成
- 缓存机制
这三个做好之后,AI浏览器的成本通常可以下降 50% 到 90%。
三、推荐架构设计
一个成本友好的 AI 浏览器架构可以设计成下面这样:
浏览器插件 / WebView / 客户端
│
│ 提取网页正文
▼
内容清洗模块
│
│ 生成 pageHash
▼
缓存层 Redis / SQLite
│
├── 已有摘要:直接返回
│
└── 未命中:继续处理
▼
文本切片 Chunking
│
▼
向量化 Embedding
│
▼
向量数据库
│
▼
用户提问
│
▼
检索相关片段
│
▼
构造 Prompt
│
▼
调用大模型
│
▼
结果缓存并返回
这个架构的关键点是:
页面只处理一次,问题只使用相关内容,不重复消耗 Token。
四、第一步:在浏览器端提取正文
AI浏览器降本的第一步,是不要把整个 HTML 发给后端。
一个网页的 HTML 里可能包含大量无用内容:
- 导航栏;
- 页脚;
- 推荐文章;
- 广告;
- 评论区;
- JavaScript;
- CSS;
- 隐藏元素;
- 侧边栏;
- 统计代码。
如果直接把完整 HTML 发给大模型,会浪费大量 Token。
比较好的做法是在浏览器端用 Readability 算法提取正文。
例如可以使用 Mozilla 的 @mozilla/readability。
浏览器端源码示例
// content-script.js
import { Readability } from '@mozilla/readability';
function extractPageContent() {
const documentClone = document.cloneNode(true);
const reader = new Readability(documentClone);
const article = reader.parse();
if (!article) {
return {
title: document.title,
url: location.href,
content: document.body.innerText.slice(0, 20000)
};
}
return {
title: article.title || document.title,
url: location.href,
content: article.textContent || '',
excerpt: article.excerpt || ''
};
}
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message.type === 'EXTRACT_PAGE') {
const data = extractPageContent();
sendResponse(data);
}
});
这样做的好处是:
- 只传正文;
- 减少无关噪声;
- 降低 Token 数量;
- 提升回答质量;
- 减少后端服务器压力。
在实际项目中,单是正文提取这一步,通常就能减少 30% 到 70% 的输入内容。
五、第二步:对网页内容生成唯一标识
同一个网页可能被多个用户打开,也可能被同一个用户多次打开。如果每次都重新总结,就会浪费成本。
我们可以对网页 URL 和正文内容生成一个 pageHash。
// hash.js
import crypto from 'crypto';
export function createPageHash(url, content) {
return crypto
.createHash('sha256')
.update(url + '\n' + content.slice(0, 5000))
.digest('hex');
}
为什么不只用 URL?
因为很多页面 URL 不变但内容会变化,例如:
- 新闻首页;
- 商品详情页;
- 文档页面;
- 动态渲染网页;
- 内部系统页面。
为什么不对全文做 hash?
可以,但长文本 hash 会略微增加计算成本。实际中可以根据业务选择:
- 对 URL + title + 前 5000 字符做 hash;
- 对完整正文做 hash;
- 对正文分段后分别做 hash。
对于大多数 AI 浏览器场景,使用 url + content.slice(0, 5000) 已经足够。
六、第三步:文本切片,避免全文进入模型
长网页不能每次都完整送给大模型。正确做法是把正文拆成多个 chunk。
切片时需要注意:
- 每个 chunk 不要太短,否则语义不完整;
- 每个 chunk 不要太长,否则检索不精准;
- chunk 之间最好有 overlap,避免上下文断裂;
- 按段落切分优于按固定字符切分。
下面是一个简单的切片函数。
// chunk.js
export function splitText(text, options = {}) {
const chunkSize = options.chunkSize || 1000;
const overlap = options.overlap || 150;
const paragraphs = text
.split(/\n+/)
.map(p => p.trim())
.filter(Boolean);
const chunks = [];
let current = '';
for (const paragraph of paragraphs) {
if ((current + paragraph).length <= chunkSize) {
current += paragraph + '\n';
} else {
if (current.trim()) {
chunks.push(current.trim());
}
if (overlap > 0 && current.length > overlap) {
current = current.slice(-overlap) + '\n' + paragraph + '\n';
} else {
current = paragraph + '\n';
}
}
}
if (current.trim()) {
chunks.push(current.trim());
}
return chunks;
}
例如一篇 2 万字网页,可以切成 20 个片段。用户提问时,只检索最相关的 3 到 5 个片段,而不是把 2 万字全部传给模型。
这就是 AI浏览器降本的核心。
七、第四步:使用向量检索减少输入 Token
切片后,我们需要把每个 chunk 转成向量,然后存入向量数据库。
用户提问时,也把问题转成向量,然后查找最相关的 chunk。
这样构造 Prompt 时只需要包含:
- 页面标题;
- 用户问题;
- 3 到 5 个相关片段。
而不是整篇文章。
Embedding 示例代码
下面是一个伪代码风格的 Node.js 示例,你可以替换成自己的模型服务。
// embedding.js
import OpenAI from 'openai';
const client = new OpenAI({
apiKey: process.env.OPENAI_API_KEY
});
export async function createEmbedding(text) {
const result = await client.embeddings.create({
model: 'text-embedding-3-small',
input: text
});
return result.data[0].embedding;
}
简单内存向量检索源码
为了方便理解,下面提供一个不依赖向量数据库的简化版本。生产环境可以替换为:
- Milvus;
- Qdrant;
- Weaviate;
- pgvector;
- Elasticsearch Vector;
- LanceDB。
// vector-store.js
function cosineSimilarity(a, b) {
let dot = 0;
let normA = 0;
let normB = 0;
for (let i = 0; i < a.length; i++) {
dot += a[i] * b[i];
normA += a[i] * a[i];
normB += b[i] * b[i];
}
return dot / (Math.sqrt(normA) * Math.sqrt(normB));
}
const memoryStore = new Map();
export function saveChunks(pageHash, chunks) {
memoryStore.set(pageHash, chunks);
}
export function searchChunks(pageHash, queryEmbedding, topK = 4) {
const chunks = memoryStore.get(pageHash) || [];
return chunks
.map(item => ({
...item,
score: cosineSimilarity(item.embedding, queryEmbedding)
}))
.sort((a, b) => b.score - a.score)
.slice(0, topK);
}
这个简化版适合本地 Demo 或小规模测试。真实业务中建议持久化存储,否则服务重启后数据会丢失。
八、第五步:摘要缓存,避免重复调用
网页摘要是 AI浏览器里最常见的功能。如果用户每次点击“总结页面”都请求大模型,那么成本会很高。
正确做法是:
- 根据 pageHash 查询缓存;
- 如果命中缓存,直接返回摘要;
- 如果没有缓存,再调用模型;
- 生成后写入缓存。
// cache.js
const cache = new Map();
export function getCache(key) {
const item = cache.get(key);
if (!item) return null;
if (item.expireAt && Date.now() > item.expireAt) {
cache.delete(key);
return null;
}
return item.value;
}
export function setCache(key, value, ttlSeconds = 86400) {
cache.set(key, {
value,
expireAt: Date.now() + ttlSeconds * 1000
});
}
缓存 key 可以这样设计:
summary:{pageHash}
qa:{pageHash}:{questionHash}
translate:{pageHash}:{targetLang}
这样不同能力之间互不影响,也方便后续统计成本。
九、第六步:分级模型,简单任务用便宜模型
AI浏览器并不是所有任务都需要最强模型。
例如:
| 任务 | 推荐模型策略 |
|---|---|
| 网页标题总结 | 小模型 |
| 关键词提取 | 小模型 |
| 文章摘要 | 中等模型 |
| 页面问答 | 中等模型 + RAG |
| 多网页综合分析 | 强模型 |
| 自动操作浏览器 | 强模型 |
| 代码分析 | 强模型 |
| 法律、医疗、金融严肃场景 | 强模型 + 审核 |
也就是说,应该根据任务复杂度选择模型。
可以封装一个模型路由器:
// model-router.js
export function selectModel(taskType) {
switch (taskType) {
case 'keyword':
case 'title':
case 'simple_summary':
return 'gpt-4o-mini';
case 'page_qa':
case 'translate':
case 'summary':
return 'gpt-4o-mini';
case 'multi_page_research':
case 'agent':
case 'code_analysis':
return 'gpt-4o';
default:
return 'gpt-4o-mini';
}
}
生产环境中还可以增加:
- 用户会员等级;
- 当前系统负载;
- 剩余额度;
- 响应时延要求;
- 模型失败后的降级策略。
例如免费用户默认使用便宜模型,付费用户可以使用更强模型。
十、第七步:构造更省 Token 的 Prompt
很多 AI 应用成本高,并不是模型贵,而是 Prompt 写得太啰嗦。
AI浏览器中的 Prompt 应该尽量结构化、简洁、明确。
不推荐的 Prompt
你是一个非常非常厉害的人工智能助手,你需要认真阅读下面这篇非常长的文章,
然后尽可能详细地回答用户问题。请注意你必须保持准确性,并且不要胡说。
下面是文章内容……
这种 Prompt 有大量无效词。
推荐的 Prompt
你是网页阅读助手。只根据给定资料回答。
如果资料不足,回答“页面中未找到相关信息”。
页面标题:{{title}}
相关资料:
{{context}}
用户问题:
{{question}}
要求:
1. 用中文回答;
2. 简洁准确;
3. 必要时列出要点。
这个 Prompt 更短、更稳定,也更容易控制输出。
十一、完整后端源码示例
下面给出一个简化版 Express 后端源码,包含:
- 页面接收;
- hash 生成;
- 文本切片;
- embedding;
- 向量保存;
- 摘要缓存;
- 页面问答;
- 模型调用。
安装依赖
npm init -y
npm install express cors openai dotenv
.env
OPENAI_API_KEY=你的API_KEY
PORT=3000
server.js
import express from 'express';
import cors from 'cors';
import crypto from 'crypto';
import dotenv from 'dotenv';
import OpenAI from 'openai';
dotenv.config();
const app = express();
app.use(cors());
app.use(express.json({ limit: '5mb' }));
const client = new OpenAI({
apiKey: process.env.OPENAI_API_KEY
});
const cache = new Map();
const vectorStore = new Map();
function hashText(text) {
return crypto.createHash('sha256').update(text).digest('hex');
}
function createPageHash(url, content) {
return hashText(url + '\n' + content.slice(0, 5000));
}
function getCache(key) {
const item = cache.get(key);
if (!item) return null;
if (item.expireAt && Date.now() > item.expireAt) {
cache.delete(key);
return null;
}
return item.value;
}
function setCache(key, value, ttlSeconds = 86400) {
cache.set(key, {
value,
expireAt: Date.now() + ttlSeconds * 1000
});
}
function splitText(text, chunkSize = 1000, overlap = 150) {
const paragraphs = text
.split(/\n+/)
.map(item => item.trim())
.filter(Boolean);
const chunks = [];
let current = '';
for (const paragraph of paragraphs) {
if ((current + paragraph).length <= chunkSize) {
current += paragraph + '\n';
} else {
if (current.trim()) chunks.push(current.trim());
if (current.length > overlap) {
current = current.slice(-overlap) + '\n' + paragraph + '\n';
} else {
current = paragraph + '\n';
}
}
}
if (current.trim()) chunks.push(current.trim());
return chunks;
}
async function createEmbedding(text) {
const result = await client.embeddings.create({
model: 'text-embedding-3-small',
input: text
});
return result.data[0].embedding;
}
function cosineSimilarity(a, b) {
let dot = 0;
let normA = 0;
let normB = 0;
for (let i = 0; i < a.length; i++) {
dot += a[i] * b[i];
normA += a[i] * a[i];
normB += b[i] * b[i];
}
return dot / (Math.sqrt(normA) * Math.sqrt(normB));
}
function searchChunks(pageHash, queryEmbedding, topK = 4) {
const chunks = vectorStore.get(pageHash) || [];
return chunks
.map(item => ({
...item,
score: cosineSimilarity(item.embedding, queryEmbedding)
}))
.sort((a, b) => b.score - a.score)
.slice(0, topK);
}
async function callLLM({ model, messages, temperature = 0.2 }) {
const completion = await client.chat.completions.create({
model,
messages,
temperature
});
return completion.choices[0].message.content;
}
app.post('/api/page/index', async (req, res) => {
try {
const { url, title, content } = req.body;
if (!url || !content) {
return res.status(400).json({ error: 'url 和 content 不能为空' });
}
const pageHash = createPageHash(url, content);
if (vectorStore.has(pageHash)) {
return res.json({
pageHash,
indexed: true,
cached: true
});
}
const chunks = splitText(content);
const embeddedChunks = [];
for (let i = 0; i < chunks.length; i++) {
const embedding = await createEmbedding(chunks[i]);
embeddedChunks.push({
id: `${pageHash}_${i}`,
pageHash,
title,
url,
text: chunks[i],
embedding
});
}
vectorStore.set(pageHash, embeddedChunks);
res.json({
pageHash,
indexed: true,
cached: false,
chunkCount: embeddedChunks.length
});
} catch (error) {
console.error(error);
res.status(500).json({ error: '页面索引失败' });
}
});
app.post('/api/page/summary', async (req, res) => {
try {
const { pageHash, title } = req.body;
if (!pageHash) {
return res.status(400).json({ error: 'pageHash 不能为空' });
}
const cacheKey = `summary:${pageHash}`;
const cached = getCache(cacheKey);
if (cached) {
return res.json({
cached: true,
summary: cached
});
}
const chunks = vectorStore.get(pageHash) || [];
const context = chunks
.slice(0, 6)
.map((item, index) => `片段${index + 1}:\n${item.text}`)
.join('\n\n');
const summary = await callLLM({
model: 'gpt-4o-mini',
messages: [
{
role: 'system',
content: '你是网页摘要助手,请基于用户提供的网页内容生成准确、简洁的中文摘要。'
},
{
role: 'user',
content: `页面标题:${title || ''}
网页内容:
${context}
请输出:
1. 一句话概括;
2. 3-5个核心要点;
3. 适合继续追问的问题。`
}
]
});
setCache(cacheKey, summary, 7 * 86400);
res.json({
cached: false,
summary
});
} catch (error) {
console.error(error);
res.status(500).json({ error: '摘要生成失败' });
}
});
app.post('/api/page/ask', async (req, res) => {
try {
const { pageHash, question, title } = req.body;
if (!pageHash || !question) {
return res.status(400).json({ error: 'pageHash 和 question 不能为空' });
}
const questionHash = hashText(question);
const cacheKey = `qa:${pageHash}:${questionHash}`;
const cached = getCache(cacheKey);
if (cached) {
return res.json({
cached: true,
answer: cached
});
}
const queryEmbedding = await createEmbedding(question);
const relatedChunks = searchChunks(pageHash, queryEmbedding, 4);
const context = relatedChunks
.map((item, index) => `资料${index + 1},相关度:${item.score.toFixed(3)}\n${item.text}`)
.join('\n\n');
const answer = await callLLM({
model: 'gpt-4o-mini',
messages: [
{
role: 'system',
content: '你是网页问答助手。只能根据给定资料回答;如果资料不足,请说明页面中未找到相关信息。'
},
{
role: 'user',
content: `页面标题:${title || ''}
相关资料:
${context}
用户问题:
${question}
回答要求:
1. 使用中文;
2. 简洁准确;
3. 如果有依据,请引用资料中的关键信息;
4. 不要编造页面中没有的内容。`
}
]
});
setCache(cacheKey, answer, 86400);
res.json({
cached: false,
answer,
usedChunks: relatedChunks.length
});
} catch (error) {
console.error(error);
res.status(500).json({ error: '问答失败' });
}
});
const port = process.env.PORT || 3000;
app.listen(port, () => {
console.log(`AI Browser backend running at http://localhost:${port}`);
});
启动服务:
node server.js
如果你使用的是 ES Module,需要在 package.json 中加上:
{
"type": "module"
}
十二、前端调用示例
假设浏览器插件已经提取到页面内容,可以这样调用后端。
async function indexPage(page) {
const response = await fetch('http://localhost:3000/api/page/index', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(page)
});
return response.json();
}
async function summarizePage(pageHash, title) {
const response = await fetch('http://localhost:3000/api/page/summary', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
pageHash,
title
})
});
return response.json();
}
async function askPage(pageHash, title, question) {
const response = await fetch('http://localhost:3000/api/page/ask', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
pageHash,
title,
question
})
});
return response.json();
}
一个典型流程是:
const page = {
url: location.href,
title: document.title,
content: document.body.innerText
};
const indexResult = await indexPage(page);
const summaryResult = await summarizePage(indexResult.pageHash, page.title);
console.log(summaryResult.summary);
const answerResult = await askPage(
indexResult.pageHash,
page.title,
'这篇文章的核心观点是什么?'
);
console.log(answerResult.answer);
十三、进一步降低成本的高级策略
如果你的 AI浏览器已经有一定用户量,还可以继续做更深入的优化。
1. 本地摘要预处理
可以先在浏览器端对正文做简单压缩:
- 删除连续空行;
- 删除重复文本;
- 删除菜单项;
- 删除版权信息;
- 删除短链接;
- 删除无意义符号。
function cleanText(text) {
return text
.replace(/\s+/g, ' ')
.replace(/相关推荐|相关文章|分享到|复制链接/g, '')
.trim();
}
虽然这个函数很简单,但在网页场景中很有效。
2. 用户问题改写
用户的问题可能很长,也可能包含无关信息。可以先用小模型把问题改写成检索 query。
但要注意:如果每次都多调用一次模型,也会增加成本。更便宜的办法是先用规则处理:
function normalizeQuestion(question) {
return question
.replace(/请问|帮我看下|我想知道|能不能告诉我/g, '')
.trim()
.slice(0, 300);
}
这样可以减少 embedding 输入长度。
3. 控制 topK
RAG 并不是检索越多越好。
topK=10 看起来更安全,但成本也更高。
一般建议:
- 简单问答:topK = 3;
- 复杂问题:topK = 5;
- 长文综合:topK = 6;
- 多网页研究:分阶段汇总。
你可以根据问题类型动态调整:
function getTopK(question) {
if (question.length < 20) return 3;
if (/总结|分析|对比|原因|影响/.test(question)) return 5;
return 4;
}
4. 限制最大输出长度
很多时候输出 Token 也很贵。
可以在 Prompt 中明确要求:
请在 300 字以内回答。
或者使用 API 参数控制最大输出长度。
const completion = await client.chat.completions.create({
model: 'gpt-4o-mini',
messages,
temperature: 0.2,
max_tokens: 500
});
这样可以防止模型输出过长。
5. 流式输出减少重复点击
用户等待时间太长时,容易重复点击按钮,导致重复请求。
流式输出虽然不一定直接减少单次 Token,但可以降低重复请求率,间接降低成本。
6. 额度系统
对于公开产品,必须设计额度系统,例如:
- 每日免费摘要 10 次;
- 每日免费问答 30 次;
- 长网页分析消耗更多额度;
- 多网页研究只对付费用户开放;
- 超大页面需要用户确认。
额度不是单纯限制用户,而是保护系统成本。
十四、成本优化效果估算
假设一篇网页原始正文约 15,000 tokens,用户问 5 个问题。
不优化方案
每次都传全文:
15,000 tokens × 5 = 75,000 input tokens
RAG 优化方案
切片后每次只取 4 个 chunk,每个 chunk 约 500 tokens:
4 × 500 × 5 = 10,000 input tokens
再加上 embedding 和摘要缓存,整体成本通常可以下降:
约 60% ~ 90%
如果同一个网页被多个用户访问,缓存命中率越高,节省越明显。
十五、生产环境建议
上面的源码适合 Demo,但生产环境还需要增强:
- 用 Redis 替代内存缓存;
- 用 PostgreSQL + pgvector 或 Qdrant 存储向量;
- 增加用户鉴权;
- 增加调用频率限制;
- 增加日志和成本统计;
- 增加任务队列;
- 增加失败重试;
- 增加模型降级策略;
- 增加隐私保护;
- 对敏感网页内容做本地处理或加密存储。
尤其是隐私问题非常重要。AI浏览器可能读取用户当前页面内容,如果用户正在浏览公司后台、邮箱、财务系统、医疗报告等页面,必须明确告知用户,并尽量做到:
- 用户主动触发才读取页面;
- 不默认上传敏感内容;
- 支持本地模式;
- 支持删除历史记录;
- 传输过程使用 HTTPS;
- 服务端不长期保存原文;
- 企业版支持私有化部署。
十六、总结
AI浏览器要想降低成本,核心不是“找一个更便宜的模型”这么简单,而是要从整体链路优化:
- 浏览器端提取正文,减少无效内容;
- 使用 pageHash 避免重复处理;
- 对长文本进行合理切片;
- 使用 embedding 和向量检索,只给模型相关片段;
- 对摘要和问答结果做缓存;
- 根据任务复杂度选择不同模型;
- 控制 Prompt 长度和输出长度;
- 使用额度系统防止滥用;
- 在生产环境引入 Redis、向量数据库和日志统计。
一句话概括:
AI浏览器降本的本质,是把“每次全文推理”改造成“页面一次索引、问题按需检索、结果尽量复用”。
只要完成这套架构,即使用户量上升,成本也会更可控,响应速度也会更快,用户体验反而更好。