创建时间: 2026-06-08最后更新: 2026-06-08

1. 背景

项目最开始调通 LLM 的方式很直接:在 api 子站的环境变量里配置 DeepSeek。

apps/api/.dev.vars
1
DEEPSEEK_API_KEY=...
2
DEEPSEEK_BASE_URL=https://api.deepseek.com/v1
3
DEEPSEEK_MODEL=deepseek-chat

这种方式适合本地开发和平台统一供给模型能力。但如果产品要支持用户自己接入三方 LLM,就会遇到一个新问题:用户可能想填自己的 OpenAI、DeepSeek、Moonshot、硅基流动、OpenRouter 或其他兼容 OpenAI Chat Completions 协议的服务。

我们希望用户可以在 web 端个人中心配置自己的 LLM,并且优先支持 OpenAI-compatible 接口,也就是 /chat/completions 这一类协议。用户的 API Key 不保存到后端数据库,聊天时优先使用用户自己的配置;如果用户没有配置,系统继续回退到平台侧 DeepSeek 配置。

这篇文章会按当前项目里的真实实现,把这条链路完整拆开。

2. 设计原则

这次实现最重要的原则是:用户 API Key 不落库

也就是说,我们不会新增 D1 表,也不会把 API Key 写入用户 profile,更不会把它作为用户资料同步到后端。

当前采用的方案是把 API Key 只保存在当前浏览器的 localStorage。用户开启本地 LLM 配置后,聊天请求会临时带上这份配置,api 子站只在本次请求里使用它转发到三方 LLM,不会保存 API Key,也不会把 API Key 写入日志。

这里需要讲清楚一个边界:API Key 虽然不落库,但聊天时仍然会从浏览器发送到我们自己的 api 子站,由 api 子站代理调用三方 LLM。它不是 永远不离开浏览器,而是 只在请求级别临时使用,不持久化

如果要做到完全不经过 api 子站,就需要前端直接请求三方 LLM。但那会遇到 CORS、流式协议适配、密钥暴露范围和不同服务商差异等问题。当前方案是在产品可控性和用户密钥持久化风险之间做的折中。

3. 整体流程

整个链路可以画成这样:

index.txt
01
用户在个人中心填写 LLM 配置
02
03
web 端保存到 localStorage
04
05
用户打开首页聊天框并发送消息
06
07
聊天 transport 读取本地 LLM 配置
08
09
请求 api 子站 /rpc/chat/inbox
10
11
api 子站校验 web access token
12
13
如果请求里带了 llmConfig,优先使用用户配置
14
15
否则回退平台 DeepSeek 环境变量
16
17
api 子站请求 {baseURL}/chat/completions
18
19
读取 OpenAI-compatible SSE 流
20
21
转换成纯文本流返回给前端
22
23
前端继续逐字展示

这条链路里,真正需要改的地方主要有四块。contracts 要让聊天请求支持可选 llmConfigweb 要在个人中心保存本地配置,也要在聊天发送时临时带上本地配置和登录 token;api 则要让聊天接口优先使用请求里的 LLM 配置。

4. 扩展聊天请求契约

前后端共用的请求结构放在 contracts 包里。我们先给聊天请求增加一个可选的 llmConfig

inbox-chat.contract.ts
01
// packages/contracts/src/chat/inbox-chat.contract.ts
02
import { z } from 'zod'
03
04
const InboxChatPartSchema = z.object({
05
type: z.string().min(1),
06
}).passthrough()
07
08
export const InboxChatMessageSchema = z.object({
09
id: z.string().optional(),
10
role: z.enum(['user', 'assistant']),
11
parts: z.array(InboxChatPartSchema).min(1).max(50),
12
})
13
14
export const InboxChatLlmConfigSchema = z.object({
15
providerName: z.string().trim().min(1).max(80).optional(),
16
baseURL: z.string().trim().url().max(300),
17
apiKey: z.string().trim().min(1).max(400),
18
model: z.string().trim().min(1).max(120),
19
})
20
21
export const InboxChatRequestSchema = z.object({
22
messages: z.array(InboxChatMessageSchema).min(1).max(20),
23
llmConfig: InboxChatLlmConfigSchema.optional(),
24
conversation: z.object({
25
name: z.string().min(1).max(120),
26
handle: z.string().min(1).max(120),
27
headline: z.string().min(1).max(200),
28
lastActive: z.string().min(1).max(80),
29
status: z.string().min(1).max(80),
30
relationship: z.string().min(1).max(120),
31
topic: z.string().min(1).max(120),
32
chemistry: z.string().min(1).max(80),
33
chemistryLabel: z.string().min(1).max(80),
34
rhythm: z.string().min(1).max(80),
35
profileNote: z.string().min(1).max(2000),
36
}),
37
})
38
39
export type InboxChatMessage = z.infer<typeof InboxChatMessageSchema>
40
export type InboxChatLlmConfig = z.infer<typeof InboxChatLlmConfigSchema>
41
export type InboxChatRequest = z.infer<typeof InboxChatRequestSchema>

这里的 llmConfig 是可选的。这样可以保留原来的平台默认 LLM 能力:用户没有配置自己的 LLM 时,请求结构仍然合法。

这几个字段的含义都比较直观。providerName 是展示用名称,比如 OpenAIDeepSeekOpenRouterbaseURL 是 OpenAI-compatible 服务地址,比如 https://api.openai.com/v1apiKey 是用户自己的 API Key;model 是模型名,比如 gpt-4o-minideepseek-chat

导出时也要把新 schema 和类型暴露出去。

index.ts
01
// packages/contracts/src/index.ts
02
export {
03
InboxChatMessageSchema,
04
InboxChatLlmConfigSchema,
05
InboxChatRequestSchema,
06
} from './chat/inbox-chat.contract'
07
08
export type {
09
InboxChatMessage,
10
InboxChatLlmConfig,
11
InboxChatRequest,
12
} from './chat/inbox-chat.contract'

5. 本地保存 LLM 配置

因为用户明确要求 API Key 不存到后端,所以我们用浏览器 localStorage 保存。

代码放在:

index.txt
1
apps/web/src/auth/local-llm-config.ts

完整实现如下:

local-llm-config.ts
01
// apps/web/src/auth/local-llm-config.ts
02
"use client"
03
04
import type { InboxChatLlmConfig } from "@repo/contracts"
05
06
const storageKey = "web:local-llm-config"
07
const localLlmConfigChangedEventName = "web-local-llm-config-changed"
08
09
export type LocalLlmConfig = InboxChatLlmConfig & {
10
enabled: boolean
11
}
12
13
function canUseStorage() {
14
return typeof window !== "undefined"
15
}
16
17
function notifyLocalLlmConfigChanged() {
18
if (canUseStorage()) {
19
window.dispatchEvent(new Event(localLlmConfigChangedEventName))
20
}
21
}
22
23
function normalizeConfig(input: LocalLlmConfig): LocalLlmConfig {
24
return {
25
enabled: input.enabled,
26
providerName: input.providerName?.trim() || "OpenAI Compatible",
27
baseURL: input.baseURL.trim().replace(/\/$/, ""),
28
model: input.model.trim(),
29
apiKey: input.apiKey.trim(),
30
}
31
}
32
33
export function readLocalLlmConfig(): LocalLlmConfig | null {
34
if (!canUseStorage()) {
35
return null
36
}
37
38
const rawValue = window.localStorage.getItem(storageKey)
39
40
if (!rawValue) {
41
return null
42
}
43
44
try {
45
const parsed = JSON.parse(rawValue) as LocalLlmConfig
46
47
if (!parsed.apiKey || !parsed.baseURL || !parsed.model) {
48
window.localStorage.removeItem(storageKey)
49
return null
50
}
51
52
return normalizeConfig(parsed)
53
} catch {
54
window.localStorage.removeItem(storageKey)
55
return null
56
}
57
}
58
59
export function readEnabledLocalLlmConfig(): InboxChatLlmConfig | null {
60
const config = readLocalLlmConfig()
61
62
if (!config?.enabled) {
63
return null
64
}
65
66
return {
67
providerName: config.providerName,
68
baseURL: config.baseURL,
69
model: config.model,
70
apiKey: config.apiKey,
71
}
72
}
73
74
export function saveLocalLlmConfig(input: LocalLlmConfig) {
75
if (!canUseStorage()) {
76
return
77
}
78
79
window.localStorage.setItem(storageKey, JSON.stringify(normalizeConfig(input)))
80
notifyLocalLlmConfigChanged()
81
}
82
83
export function clearLocalLlmConfig() {
84
if (!canUseStorage()) {
85
return
86
}
87
88
window.localStorage.removeItem(storageKey)
89
notifyLocalLlmConfigChanged()
90
}
91
92
export { localLlmConfigChangedEventName }

这段本地存储逻辑还要把几个边界处理好。canUseStorage() 用来判断当前是否在浏览器环境,Next.js 里即使是客户端组件,也要避免在不合适的时机直接访问 window。保存前会做一次 normalizeConfig,比如去掉 baseURL 末尾的 /,避免后面拼接接口时出现双斜杠。readEnabledLocalLlmConfig() 只在配置开启时返回数据,也就是说,用户可以先保存配置但不启用,聊天时不会使用它。clearLocalLlmConfig() 则会直接删除本地存储里的 API Key。

6. 个人中心配置入口

个人中心页面中新增了一个 LLM 接入 模块。

这个模块里会放启用开关、Provider 输入框、Base URL 输入框、Model 输入框、API Key 密码输入框,以及保存按钮和删除 Key 按钮。

默认配置是:

user-center.tsx
1
function createDefaultLlmConfig(): LocalLlmConfig {
2
return {
3
enabled: false,
4
providerName: "OpenAI Compatible",
5
baseURL: "https://api.openai.com/v1",
6
model: "gpt-4o-mini",
7
apiKey: "",
8
}
9
}

页面加载后,从本地读取已保存的配置。

user-center.tsx
1
useEffect(() => {
2
const savedConfig = readLocalLlmConfig()
3
4
if (savedConfig) {
5
setLlmConfig(savedConfig)
6
}
7
}, [])

这里没有把读取逻辑直接放在 useState 初始化里,是为了避免首屏水合阶段访问浏览器存储带来不稳定行为。

保存逻辑如下:

user-center.tsx
01
function handleSaveLlmConfig() {
02
const nextConfig = {
03
...llmConfig,
04
providerName: llmConfig.providerName?.trim() || "OpenAI Compatible",
05
baseURL: llmConfig.baseURL.trim(),
06
model: llmConfig.model.trim(),
07
apiKey: llmConfig.apiKey.trim(),
08
}
09
10
if (!nextConfig.baseURL || !nextConfig.model || !nextConfig.apiKey) {
11
setLlmConfigNotice("请完整填写 Base URL、Model 和 API Key。")
12
return
13
}
14
15
saveLocalLlmConfig(nextConfig)
16
setLlmConfig(nextConfig)
17
setLlmConfigNotice(
18
nextConfig.enabled
19
? "已保存到当前浏览器,聊天时会优先使用。"
20
: "已保存到当前浏览器,开启后聊天才会使用。",
21
)
22
}

删除逻辑也很直接:

user-center.tsx
1
function handleClearLlmConfig() {
2
clearLocalLlmConfig()
3
setLlmConfig(createDefaultLlmConfig())
4
setLlmConfigNotice("本地 API Key 已删除。")
5
}

输入框更新时有一个容易踩坑的地方。不要把 event.currentTarget.value 放进 setState 的 updater 回调里。

错误写法类似这样:

user-center.tsx
1
setLlmConfig((current) => ({
2
...current,
3
baseURL: event.currentTarget.value,
4
}))

在 React 的某些执行时机里,updater 执行时 event.currentTarget 可能已经是 null,会报:

error.txt
1
Cannot read properties of null (reading 'value')

正确写法是先同步取值:

user-center.tsx
1
onChange={(event) => {
2
const value = event.currentTarget.value
3
setLlmConfig((current) => ({ ...current, baseURL: value }))
4
setLlmConfigNotice("")
5
}}

这个细节虽然小,但在表单里很实用。

7. 聊天请求带上配置

首页聊天组件使用的是 AI SDK 的 TextStreamChatTransport

原来只给请求体传了 conversation,后来改成使用 prepareSendMessagesRequest,在每次发送前动态读取本地配置。

inbox-chat.tsx
01
// apps/web/app/(dashboard)/_components/inbox-chat.tsx
02
const transport = useMemo(
03
() => new TextStreamChatTransport<UIMessage>({
04
api: `${getWebClientEnv().NEXT_PUBLIC_API_BASE_URL}/rpc/chat/inbox`,
05
prepareSendMessagesRequest({ api, body, messages }) {
06
const storedSession = readClientSession()
07
const localLlmConfig = readEnabledLocalLlmConfig()
08
09
return {
10
api,
11
headers: storedSession
12
? { authorization: `Bearer ${storedSession.accessToken}` }
13
: undefined,
14
body: {
15
...body,
16
messages,
17
conversation,
18
...(localLlmConfig ? { llmConfig: localLlmConfig } : {}),
19
},
20
}
21
},
22
}),
23
[conversation],
24
)

这里做了两件事。我们需要显式补上 Authorization 请求头,因为聊天 transport 不走项目里封装的 http 模块,所以不会自动带 access token。如果不在这里补,后端加了鉴权以后,聊天接口会返回未登录。除此之外,还要读取当前启用的本地 LLM 配置。

inbox-chat.tsx
1
const localLlmConfig = readEnabledLocalLlmConfig()

如果配置存在且开启,就把它放入请求体:

inbox-chat.tsx
1
...(localLlmConfig ? { llmConfig: localLlmConfig } : {})

如果用户没有开启本地 LLM 配置,请求里就不会有 llmConfig 字段,后端自然回退到平台配置。

8. 后端校验登录态

既然聊天接口现在可能临时携带用户 API Key,那么这个接口就不能裸奔。

api 子站新增了 web access token 校验:

inbox.route.ts
01
// apps/api/src/routes/chat/inbox.route.ts
02
async function requireWebAccessToken(c: Context<{ Bindings: ApiBindings }>) {
03
const authorization = c.req.header('authorization')
04
05
if (!authorization?.startsWith('Bearer ')) {
06
throw authUnauthorizedError('Access token is required')
07
}
08
09
const token = authorization.slice('Bearer '.length).trim()
10
11
if (!token) {
12
throw authUnauthorizedError('Access token is required')
13
}
14
15
const env = getApiEnv(c.env)
16
17
try {
18
return await verifyAccessToken({
19
token,
20
secret: env.JWT_ACCESS_SECRET,
21
expectedApp: 'web',
22
})
23
} catch {
24
throw authUnauthorizedError('Access token is invalid')
25
}
26
}

然后在聊天路由开头调用:

inbox.route.ts
1
await requireWebAccessToken(c)

这样只有已登录的 web 用户才能请求聊天代理接口。

9. 后端选择 LLM 配置

后端需要同时支持两种来源:请求体里的用户本地 LLM 配置,以及平台环境变量里的 DeepSeek 配置。我们用一个小函数统一解析。

inbox.route.ts
01
type ChatProviderConfig = {
02
apiKey: string
03
baseURL: string
04
model: string
05
}
06
07
function resolveChatProviderConfig(params: {
08
payload: {
09
llmConfig?: {
10
apiKey: string
11
baseURL: string
12
model: string
13
}
14
}
15
env: ReturnType<typeof getApiEnv>
16
}): ChatProviderConfig {
17
const localConfig = params.payload.llmConfig
18
19
if (localConfig) {
20
return {
21
apiKey: localConfig.apiKey,
22
baseURL: localConfig.baseURL,
23
model: localConfig.model,
24
}
25
}
26
27
if (!params.env.DEEPSEEK_API_KEY) {
28
throw new AppError(BizCode.SYSTEM_INTERNAL_ERROR, 'LLM API key is not configured', 500)
29
}
30
31
return {
32
apiKey: params.env.DEEPSEEK_API_KEY,
33
baseURL: params.env.DEEPSEEK_BASE_URL ?? 'https://api.deepseek.com/v1',
34
model: params.env.DEEPSEEK_MODEL ?? 'deepseek-chat',
35
}
36
}

这段代码的优先级很明确:请求里带了 llmConfig,就使用用户配置;没带 llmConfig,就使用平台 DeepSeek;如果平台也没有配置 API Key,再返回服务端配置错误。

这样就不会破坏已有功能。

10. 调用 OpenAI-compatible 接口

OpenAI-compatible 的核心约定是:

index.txt
1
POST {baseURL}/chat/completions
2
Authorization: Bearer {apiKey}
3
Content-Type: application/json

请求体一般长这样:

index.json
1
{
2
"model": "gpt-4o-mini",
3
"messages": [],
4
"stream": true
5
}

当前后端实现:

inbox.route.ts
01
const upstream = await fetch(`${providerConfig.baseURL.replace(/\/$/, '')}/chat/completions`, {
02
method: 'POST',
03
headers: {
04
authorization: `Bearer ${providerConfig.apiKey}`,
05
'content-type': 'application/json',
06
},
07
body: JSON.stringify({
08
model: providerConfig.model,
09
messages,
10
stream: true,
11
}),
12
signal: c.req.raw.signal,
13
})

这里用 replace(/\/$/, '') 去掉 baseURL 尾部斜杠,避免出现:

index.txt
1
https://api.openai.com/v1//chat/completions

signal: c.req.raw.signal 的作用是:如果前端取消请求,后端也能中断上游请求。

11. 处理上游错误

如果三方 LLM 返回错误,不能把上游响应原文直接透传给前端。

原因是有些服务商的错误信息可能包含敏感上下文,甚至有可能出现和鉴权相关的信息。我们的实现只记录状态码,不记录响应正文,也不把正文放进 API 响应。

inbox.route.ts
01
if (!upstream.ok) {
02
console.warn('Chat completion stream failed', {
03
status: upstream.status,
04
})
05
06
throw new AppError(
07
BizCode.SYSTEM_INTERNAL_ERROR,
08
'Chat completion stream failed',
09
500,
10
)
11
}

这个处理虽然会让前端错误信息不那么详细,但安全边界更清楚。

12. SSE 转纯文本流

OpenAI-compatible 的流式接口一般返回 SSE,格式类似:

index.txt
1
data: {"choices":[{"delta":{"content":"你"}}]}
2
data: {"choices":[{"delta":{"content":"好"}}]}
3
data: [DONE]

前端当前使用的是 TextStreamChatTransport,它期望后端返回纯文本流。因此 api 子站需要把 SSE 里的 delta.content 抽出来,再逐段写入 ReadableStream

核心逻辑如下:

inbox.route.ts
01
const textStream = new ReadableStream<Uint8Array>({
02
async start(controller) {
03
const encoder = new TextEncoder()
04
const decoder = new TextDecoder()
05
const reader = upstream.body?.getReader()
06
let buffer = ''
07
let closed = false
08
09
if (!reader) {
10
controller.close()
11
return
12
}
13
14
try {
15
while (true) {
16
const { done, value } = await reader.read()
17
18
if (done || closed) {
19
break
20
}
21
22
buffer += decoder.decode(value, { stream: true })
23
24
const lines = buffer.split('\n')
25
buffer = lines.pop() ?? ''
26
27
for (const line of lines) {
28
const trimmed = line.trim()
29
30
if (!trimmed.startsWith('data:')) {
31
continue
32
}
33
34
const data = trimmed.slice(5).trim()
35
36
if (!data) {
37
continue
38
}
39
40
if (data === '[DONE]') {
41
closed = true
42
controller.close()
43
break
44
}
45
46
const parsed = JSON.parse(data) as {
47
choices?: Array<{ delta?: { content?: unknown } }>
48
}
49
const content = parsed.choices?.[0]?.delta?.content
50
51
if (typeof content === 'string' && content) {
52
controller.enqueue(encoder.encode(content))
53
}
54
}
55
56
if (closed) {
57
break
58
}
59
}
60
61
if (!closed) {
62
controller.close()
63
}
64
} catch (error) {
65
controller.error(error)
66
}
67
},
68
})

最后返回纯文本流:

inbox.route.ts
1
return new Response(textStream, {
2
headers: {
3
'content-type': 'text/plain; charset=utf-8',
4
'cache-control': 'no-cache',
5
'x-accel-buffering': 'no',
6
},
7
})

这样前端不用关心上游 SSE 的细节,仍然只接收一段连续文本流。

13. 保留平台回退

用户自定义 LLM 是增强能力,但不能让它变成聊天功能的唯一入口。

保留平台回退之后,新用户不配置 LLM,也能立即体验聊天;用户配置错误时,也可以关闭本地 LLM,回到平台默认模型。本地开发仍然可以继续用 .dev.vars 里的 DeepSeek 配置调试,后端代码也只有一个聊天入口,不需要拆成两套路由。

回退逻辑就是前面这段:

inbox.route.ts
01
if (localConfig) {
02
return {
03
apiKey: localConfig.apiKey,
04
baseURL: localConfig.baseURL,
05
model: localConfig.model,
06
}
07
}
08
09
return {
10
apiKey: params.env.DEEPSEEK_API_KEY,
11
baseURL: params.env.DEEPSEEK_BASE_URL ?? 'https://api.deepseek.com/v1',
12
model: params.env.DEEPSEEK_MODEL ?? 'deepseek-chat',
13
}

这就是配置化设计里很重要的一点:新能力应该接入现有链路,而不是把现有链路直接打断。

14. 安全边界

这套方案的安全边界,我们可以拆开来看。

用户 API Key 不保存到后端数据库。当前没有新增 D1 表,也没有把 API Key 放进用户 profile,它只存在当前浏览器的 localStorage

聊天时 API Key 会临时发送到 api 子站,这是为了由 api 子站统一处理鉴权、上下文拼装、上游流式协议解析和响应格式转换。

api 子站不记录 API Key。上游失败时只记录 HTTP 状态码,不记录响应正文,也不把上游详情透传给前端。

聊天代理接口也必须鉴权。因为它可以代理请求三方 LLM,所以不能允许未登录用户直接调用。

15. 常见问题

1. API Key 为什么不用 D1 加密保存

因为当前需求明确要求 不存储用户的 API Key,就像添加到本地 web 配置一样

所以这次没有使用 D1,也没有新增加密存储逻辑。浏览器本地保存更符合这个产品决策。

2. localStorage 保存 API Key 安全吗

它不是最高安全级别的存储方案。

localStorage 里的数据可以被同源脚本读取,因此要特别注意 XSS 防护。当前方案的定位是 本机配置,适合用户自愿在当前浏览器保存自己的 key。

如果未来要做企业级或多设备同步,就应该改成服务端加密存储、访问审计、权限管理和密钥轮换,而不是继续用 localStorage。

3. 为什么不让前端直接请求 OpenAI-compatible 服务

主要原因有几个。很多服务商未必允许浏览器跨域请求;不同服务商虽然都说兼容 OpenAI,但流式响应细节仍可能有差异,后端代理更容易统一适配;前端直连还会让业务上下文、系统提示词、模型协议处理都散落到浏览器里,不利于统一维护。

4. 为什么请求里还要带 access token

聊天接口现在相当于一个 LLM 代理接口。如果不鉴权,任何人都可以请求它,甚至可能借平台默认 DeepSeek 配置消耗资源。

所以聊天 transport 里必须显式带上:

inbox-chat.tsx
1
headers: storedSession
2
? { authorization: `Bearer ${storedSession.accessToken}` }
3
: undefined

后端也必须校验:

inbox.route.ts
1
await requireWebAccessToken(c)

5. 用户关闭本地 LLM 后会怎样

readEnabledLocalLlmConfig() 会返回 null,聊天请求就不会带 llmConfig

后端收到请求后找不到本地配置,就会使用平台 DeepSeek 配置。

总结

这次 LLM 配置化实现的核心不是多几个输入框,而是把模型调用链路从 平台固定 DeepSeek 改造成 用户请求级配置优先,平台配置兜底

我们把 contracts 增加了可选 llmConfig,让 web 端可以把用户配置保存到当前浏览器,并在聊天请求里临时带上启用的本地 LLM 配置。api 子站优先使用请求里的 OpenAI-compatible 配置;用户未配置时,再继续回退平台 DeepSeek。

这套设计让产品先具备了开放接入三方 LLM 的能力,同时又避免把用户 API Key 持久化到后端。对一个正在快速迭代的 AI Agent 产品来说,这是一个足够轻、也足够清晰的落地方案。