平台:sichang.xyz
本文适合有基础前端/全栈经验的读者
为什么要写这篇文章
"你说不存我的对话,我怎么相信你?"
这是我在开发思畅AI时被问到最多的问题。承诺本身没有意义——任何产品都可以在落地页写上"我们重视您的隐私"。
所以这篇文章不讲承诺,讲实现。我会拆解每一层:对话怎么在浏览器里加密存储、发送 API 请求时到底传了什么、Venice 和 OpenRouter 是怎么处理这些数据的,以及我们服务器的数据库里实际记录了什么。
读完这篇文章,你应该能用 DevTools 自己验证这些声明。
整体架构
先看宏观数据流:
你的浏览器
│
├─ 对话历史 ──────► IndexedDB(AES-256-GCM 加密存储,密钥只在内存 / IndexedDB)
│
└─ 发起推理请求
│
▼
思畅 代理层(/api/chat)
│ ← 只做转发,记录用量计数(数字),不写 prompt/response
│
├─ Venice AI(GLM-4.7)
│ └─ 处理完丢弃,不持久化
│
└─ OpenRouter(DeepSeek-V3.2 / Gemma / MiniMax / GLM-5)
└─ 默认不记录 prompt,仅保留计费元数据
关键点:对话内容从未以明文出现在思畅的服务器数据库里。服务器唯一知道的是"你今天发了多少条消息"——一个整数。
第一层:浏览器本地加密存储
加密算法选型
我们使用浏览器原生的 Web Crypto API,算法是 AES-256-GCM(AES Galois/Counter Mode):
- 256-bit 密钥:当前最高强度对称加密
- GCM 认证模式:加密的同时提供完整性校验,篡改密文会被检测到
- 每次加密随机 12-byte IV:即使加密相同内容,密文也完全不同
- 零第三方依赖:所有操作调用浏览器自带的
crypto.subtle,不引入任何外部加密库
完整实现(src/lib/crypto.ts):
const ALGO = 'AES-GCM'
const KEY_LENGTH = 256
const IV_LENGTH = 12 // 96-bit IV,GCM 推荐长度
/** 加密字符串,返回 base64(iv[12B] + ciphertext) */
export async function encrypt(plaintext: string, key: CryptoKey): Promise<string> {
const enc = new TextEncoder()
const iv = crypto.getRandomValues(new Uint8Array(IV_LENGTH)) // 每次随机
const ciphertext = await crypto.subtle.encrypt(
{ name: ALGO, iv },
key,
enc.encode(plaintext)
)
// IV 和密文拼接后 base64 编码
const combined = new Uint8Array(iv.byteLength + ciphertext.byteLength)
combined.set(iv, 0)
combined.set(new Uint8Array(ciphertext), iv.byteLength)
return btoa(String.fromCharCode(...combined))
}
存储格式示意:
IndexedDB messages 表
┌─────────────────────────────────────────────────────────────┐
│ id: "msg_abc123" (明文,用于索引) │
│ conversationId: "conv_xyz" (明文) │
│ role: "user" (明文) │
│ createdAt: 1745123456789 (明文时间戳) │
│ contentCipher: Uint8Array[IV(12B) + AES-GCM密文] ← 这是内容 │
└─────────────────────────────────────────────────────────────┘
消息 ID、时间戳、会话 ID 以明文存储(需要用来排序和检索),只有消息内容被加密。即使有人拿到你的 IndexedDB 文件,看到的消息内容也是一段无法解读的二进制数据。
两种密钥管理方案
方案 A:已登录用户——从密码派生密钥
登录后,密钥通过 PBKDF2(Password-Based Key Derivation Function 2)从你的密码派生:
export async function deriveKey(password: string, saltHex: string): Promise<CryptoKey> {
const enc = new TextEncoder()
// Step 1: 将密码导入为原始密钥材料
const keyMaterial = await crypto.subtle.importKey(
'raw',
enc.encode(password),
{ name: 'PBKDF2' },
false,
['deriveKey'],
)
// Step 2: PBKDF2 派生 AES-GCM 密钥
return crypto.subtle.deriveKey(
{
name: 'PBKDF2',
salt: hexToBytes(saltHex).buffer,
iterations: 310000, // NIST 2023 推荐值,SHA-256 下至少 210,000 次
hash: 'SHA-256',
},
keyMaterial,
{ name: 'AES-GCM', length: 256 },
false, // extractable: false — 密钥无法被 JS 代码导出!
['encrypt', 'decrypt'],
)
}
extractable: false 这个参数很关键:
- 派生出的
CryptoKey对象被浏览器标记为"不可导出" - 即使攻击者注入了 JS 代码,也无法通过
crypto.subtle.exportKey()提取密钥 - 密钥以不可导出的形式存储在 IndexedDB 的
sichang_crypto数据库中
saltHex 来自服务器(每个用户唯一,登录时随 session 返回)。密钥推导公式:
CryptoKey = PBKDF2(SHA-256, password, userSalt, iterations=310_000, keyLen=256bit)
服务器存储的是:
userSalt(随机 32 字节,16 进制)bcrypt(password)(密码 hash)
服务器永远不知道派生出的 CryptoKey 是什么。 服务器有 salt,但没有密码,所以无法自己执行 PBKDF2。
方案 B:匿名用户——随机生成密钥
export async function generateAnonKey(): Promise<CryptoKey> {
return crypto.subtle.generateKey(
{ name: 'AES-GCM', length: 256 },
true, // 匿名密钥可导出,需要存入 localStorage
['encrypt', 'decrypt'],
)
}
// 导出为 JWK 格式存入 localStorage['sichang_anon_key']
const jwk = await crypto.subtle.exportKey('jwk', key)
localStorage.setItem('sichang_anon_key', JSON.stringify(jwk))
匿名密钥以 JWK(JSON Web Key)格式存在 localStorage。这个密钥从不上传服务器——它的防护目标是服务端,不是本地物理访问(本地攻击者如果能访问你的 localStorage,安全边界已经被突破了)。
⚠️ 注意:清除浏览器缓存 = 匿名密钥丢失 = 对话永久无法恢复。界面有明确提示。
第二层:API 请求——传了什么,没传什么
这是很多人担心的核心问题:你们的服务器作为中间代理,会不会偷偷记录消息?
来看实际的 /api/chat 处理代码(关键逻辑):
export async function POST(req: Request) {
const { messages, model, veniceParameters, characterId } = body
// 1. 验证 session、检查用量配额
const session = await getServerSession(authOptions)
// ...
// 2. 转发给 AI 提供商(Venice 或 OpenRouter)
const apiRes = isVeniceModel
? await fetch('https://api.venice.ai/api/v1/chat/completions', {
method: 'POST',
headers: { Authorization: `Bearer ${process.env.VENICE_API_KEY}` },
body: JSON.stringify({ model, messages, stream: true }),
})
: await fetch('https://openrouter.ai/api/v1/chat/completions', {
method: 'POST',
headers: { Authorization: `Bearer ${process.env.OPENROUTER_API_KEY}` },
body: JSON.stringify({ model, messages, stream: true }),
})
// 3. 更新用量计数(只记一个数字,不记内容)
await prisma.usageLog.upsert({
where: { userId_date: { userId, date: today } },
update: { messageCount: { increment: 1 } }, // 只加 1
create: { userId, date: today, messageCount: 1 },
// ← 没有 messages 字段,没有 model 字段,只有一个计数
})
// 4. 把 AI 的流式响应直接透传给浏览器
return new Response(apiRes.body, {
headers: { 'Content-Type': 'text/event-stream' },
})
}
数据库写入只有一行:messageCount + 1。
对于匿名用户,我们甚至不存 IP 地址本身:
// 匿名用量限制:IP 哈希后存储,无法反查原始 IP
const ipHash = createHash('sha256').update(ip).digest('hex')
await prisma.anonymousUsage.upsert({
where: { ipHash_date: { ipHash, date: today } },
update: { messageCount: { increment: 1 } },
create: { ipHash, date: today, messageCount: 1 },
})
你可以自己验证:
- 打开 DevTools → Network
- 发送一条消息
- 找到
/api/chat的请求 - 查看 Request Payload:你会看到
messages数组(明文发给我们服务器,这是必须的) - 没有任何 Response 存储操作——响应直接 stream 回浏览器,服务器不缓存
如果你想更严格地验证,可以在发送消息前后查询数据库——你只会看到
messageCount变化了 1。
第三层:推理提供商的隐私保障
消息会以明文从我们的代理层转发给 Venice 或 OpenRouter——这是不可避免的,AI 推理必须看到明文才能处理。那这两个提供商会存储你的对话吗?
Venice AI
Venice 的架构设计从根源上避免了内容存储:
你的请求
│
▼
Venice 代理层(剥离 IP、用户标识符等元数据)
│
▼
去中心化 GPU 池(Akash 等网络,多个独立 provider)
│ 每个 GPU provider 只看到单次请求,不知道用户身份
▼
开源模型推理(DeepSeek / Qwen / GLM)
│ 推理完成后,prompt 从 GPU 内存中清除
▼
响应返回
Venice 的官方隐私政策明确:在 Private Mode(默认模式)下,不存储任何 prompt 或 response。
他们还提供了更高级别的隐私模式:
- TEE 模式:在可信执行环境(Trusted Execution Environment)中运行,即使 Venice 自己也无法访问推理过程
- E2EE 模式:端到端加密,解密只在经过验证的 TEE 内部发生
OpenRouter
OpenRouter 默认不记录 prompt 和 completion,只保留计费所需的元数据(时间戳、模型、token 数量、延迟)。
他们提供 Zero Data Retention(ZDR) 选项,可以针对每个请求或全局开启,确保连元数据也不被底层模型提供商保留。思畅已全局开启 ZDR,所有经 OpenRouter 转发的请求均带有零数据留存标记。
需要诚实说明的一点:OpenRouter 的数据处理最终也取决于底层模型提供商(DeepSeek、Google 等)各自的隐私政策。OpenRouter 不能改变这些提供商自己的数据政策,但 ZDR 模式会在 API 层面要求提供商不保留数据。
整体威胁模型总结
| 攻击场景 | 思畅服务器被攻破 | 思畅数据库泄露 | 第三方提供商泄露 | 你的设备被物理访问 |
|---|---|---|---|---|
| 对话内容暴露 | ❌ 无 | ❌ 无 | ⚠️ 取决于提供商 | ⚠️ IndexedDB 可读 |
| 账号信息暴露 | ⚠️ 邮箱 + bcrypt hash | ⚠️ 邮箱 + bcrypt hash | ❌ 无 | ❌ 无 |
| 可解密历史对话 | ❌ 没有密钥 | ❌ 没有密钥 | ❌ 不存储 | ⚠️ 若能提取密钥 |
对话内容对思畅服务器来说是不可读的——不是因为我们道德高尚,而是因为我们物理上没有解密所需的密钥。
已知局限性(诚实说明)
1. 推理阶段是明文的
消息在 Venice/OpenRouter 推理时必须是明文——这是所有云端 AI 推理产品的共同限制,不是思畅特有的。如果你需要绝对零信任的推理,只能本地跑模型(Ollama 等)。
2. 传输层依赖 HTTPS
消息从你的浏览器到思畅代理层是 HTTPS 加密传输,不是端到端加密——思畅代理看到明文后再转发给提供商。我们没有在传输层做额外的客户端加密(那会要求你在浏览器里管理 TLS 证书,极度影响用户体验)。
3. IndexedDB 本地可访问
如果攻击者能在你的浏览器中执行任意 JS(XSS 攻击),他们可能在密钥加载进内存时读取内容。这是浏览器端加密的通用局限,不是思畅特有的问题。
4. 匿名密钥无需密码保护
匿名用户的密钥以 JWK 明文存在 localStorage,防护目标是服务端,不是本地攻击者。
怎么验证我们说的是真的
打开 sichang.xyz,发几条消息,然后:
验证 1:服务器没有存对话内容
- DevTools → Network → 找
/api/chat - 查看 Response(是 SSE 流)
- 全局搜索 Network 请求,没有任何
POST /api/conversations或类似的存储接口
验证 2:对话确实存在本地
- DevTools → Application → IndexedDB →
sichang-db→messages - 你会看到
contentCipher字段是一段 Uint8Array 二进制,不是可读文字
验证 3:密钥不会上传
- DevTools → Network,过滤所有请求
- 你不会看到任何包含加密密钥的请求
为什么选择这个架构
说实话,"对话存本地 + 服务器仅做代理"这个架构对产品开发来说有很多不便:
- 对话无法云同步(我们后来加了可选的加密云同步功能)
- 无法做基于对话内容的个性化推荐
- 无法训练自己的模型
- 用量统计只能做聚合数字,没有细粒度分析
但这正是我们希望做出的取舍。我们物理上没有能力出卖你的数据,即使将来遭遇法律要求也没有内容可以交出。
这是思畅面向海外华人的核心承诺:不是"我们承诺不看",而是"我们根本看不到"。
最后
如果你发现上述任何描述与实际实现不符,或者有安全问题想反馈,欢迎发邮件或在评论区指出。我们会认真对待每一条安全相关的反馈。
思畅AI 目前处于早期阶段,欢迎体验:sichang.xyz
如果觉得这篇文章有帮助,点个赞让更多人看到。
如果你在做类似隐私优先产品,也欢迎交流技术实现。