AccessAI 这个项目我维护了一年多,最初只是给自己拼的一个聚合查询小工具,把不同模型厂商的 API 塞到一个页面里,省得来回切网页。后来陆续有人找到我,说也想用这种一站式的对话工具,我干脆把它拆成了开源项目。这轮更新算是我自己最满意的一次:界面整体重做,模型接入从单模型变成多模型,对话上下文和会话历史也都补上了,项目从"能用"迈到了"好用"的阶段。
先说清楚 AccessAI 是什么。它是一个开源的 AI 对话聚合应用,提供 Web 界面,能统一接入 OpenAI、Anthropic Claude、Google Gemini、DeepSeek、通义千问等大模型服务。你在一个对话框里可以随时切换不同模型,多轮对话的上下文会自动带上,所有的会话记录都会存到本地数据库里,随时翻查、导出。这次更新解决的核心痛点有三个:一是原来那种"每个模型一个网页"的使用方式太零碎,二是切换模型后上下文经常丢失,三是聊过的内容关了浏览器就没了。
如果你也在做类似的 AI 对话项目,或者你想本地搭一个可以自由切换多模型、能保存历史的对话工具,这篇更新记录应该对你有点用。下面我会把新界面、多模型接入、上下文维护、历史管理这几块的设计思路和实现细节都拆开讲,包括我踩过的坑和排查方法。
1. 这次更新到底做了什么
1.1 项目定位与要解决的问题
AccessAI 从立项开始就不是奔着做一个"ChatGPT 套壳站"去的,我更想把它做成本地私有化的模型网关加对话工作台。周围不少朋友用 ChatGPT 用得好好的,但公司项目里要求数据不能出内网,或者有的人订阅了多个 AI 服务,每个月交好几份钱,实际只用到其中一两个。AccessAI 的定位就是让这些人在同一个界面里,按需调用自己已经有的 API,密钥自己保管,数据存在本地,不依赖任何第三方平台。
这次更新之前,项目的状态挺尴尬的:界面是 Bootstrap 拼的,模型写死了一个,对话没有上下文,刷新页面就失忆。从实际使用的角度来看,这几个缺失很致命。没有上下文,意味着每次提问都要把背景资料重新贴一遍,多轮对话完全没法做;没有历史管理,意味着你无法回头检索之前聊过的重要内容。所以这次更新我把"新界面、多模型、对话上下文、历史管理"四个点作为一个整体来设计,因为它们互相之间是有依赖关系的。
界面的价值在于降低多模型的切换成本,上下文的价值在于让切换模型之后还能接得上话,而历史管理的价值在于让所有对话沉淀下来,变成可检索的知识库。在设计时我没有把它们当成四个独立功能去堆,而是梳理成一条主线:用户发起对话 -> 系统根据当前会话组织上下文 -> 调用当前选中的模型 -> 流式返回结果 -> 结果连同上下文一起写入会话历史。
1.2 整体架构调整
这轮更新的架构分成三层。最上层是前端界面,采用 Vue 3 加 Element Plus 重写,负责对话展示、模型切换、流式输出渲染和会话管理。中间层是 API 服务,基于 FastAPI 实现,对外提供会话和消息相关的 REST 接口,对内统一封装不同模型厂商的调用逻辑。最底层是存储层,使用 PostgreSQL 保存用户、会话和消息数据,Redis 做临时缓存和流式输出的缓冲。
选择 FastAPI 有几个实际原因。一是异步支持天然适合大模型场景,流式输出不会阻塞其他请求;二是 Pydantic 做参数校验非常方便,不同模型厂商的参数差异可以在序列化时就规范掉;三是自动生成 OpenAPI 文档,前端对接起来明确。存储层为什么不用 SQLite?主要是因为会话和消息的写入频率不低,SQLite 在并发写入时会有锁竞争问题,多用户场景下体验不好。PostgreSQL 在这个量级下完全够用,而且支持全文检索,后面做历史消息搜索的时候可以直接用。
后端 API 的路径设计围绕会话和消息两条线展开:
text复制GET /api/v1/conversations 获取会话列表
POST /api/v1/conversations 创建新会话
GET /api/v1/conversations/{id} 获取某个会话的详情
DELETE /api/v1/conversations/{id} 删除会话
POST /api/v1/conversations/{id}/messages 发送消息
GET /api/v1/conversations/{id}/messages 获取消息列表
GET /api/v1/models 获取可用模型列表
GET /api/v1/models/{provider} 获取某个厂商的模型配置
这套接口设计遵循一个原则:前端不直接感知模型厂商的差异,只和会话、消息打交道。至于当前会话用的是哪个模型,是存在会话对象里的一个普通字段。这样后续加新模型,前端几乎不用改。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 新界面:从能用变成好用
2.1 为什么重写前端
旧版界面最大的问题不是丑,而是交互逻辑混乱。模型切换需要进设置页改配置,保存之后还要刷新页面才生效;聊天记录只能看到当前浏览器会话的,没有一个统一的入口去管理。与其继续打补丁,不如直接重写。
前端我选了 Vue 3 组合式 API,配合 Element Plus 组件库。选型上没太多纠结,Vue 在国内社区生态成熟,Element Plus 的表单、按钮、下拉菜单这些基础组件够用,对话界面里的气泡、输入框、侧边栏都可以基于它快速搭建。状态管理用的 Pinia,主要管理三个全局状态:当前会话对象、当前选中的模型配置、系统设置信息。
界面布局参考了主流对话产品的三段式结构:左侧是会话历史栏,中间是对话主区域,右侧是可折叠的模型配置面板。左侧历史栏支持会话的搜索、重命名、删除,会话按更新时间倒序排列。中间对话区每一条消息都有独立的操作菜单,可以复制内容、重新生成回复、编辑已发送的消息。右侧配置面板里可以调整温度、最大 token 数、Top P 等生成参数,这些参数会随着当前会话保存。
2.2 流式输出与渲染细节
对话体验里最影响观感的就是流式输出。如果 API 返回是分段的,前端必须做到边接收边渲染,不能等全部完成后一次性显示。我采用的是 Server-Sent Events 配合 fetch 的 ReadableStream 来读取数据流。SSE 比 WebSocket 更轻量,因为这里只需要服务端单向推送数据,不需要客户端频繁向服务端发消息。
前端读取流的实现大致是这样:
javascript复制async function streamChat(payload, onChunk) {
const response = await fetch('/api/v1/conversations/' + payload.conversationId + '/messages', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
const reader = response.body.getReader();
const decoder = new TextDecoder('utf-8');
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop();
for (const line of lines) {
if (line.startsWith('data: ')) {
const data = JSON.parse(line.slice(6));
onChunk(data);
}
}
}
}
这里有个容易踩的坑:流式数据是按 chunk 到达的,一个完整的 JSON 可能被拆在两次 read 之间,必须先用缓冲把数据累积起来,按换行符切分后再处理。一开始我没做缓冲处理,输出经常出现半行 JSON,后来补上 buffer 逻辑才稳定。
Markdown 渲染用的 remark 和 rehype 系列插件,先后经过 remarkParse -> remarkGfm -> rehypeRaw -> rehypeSanitize -> rehypeStringify 这条管线。特别注意 rehypeSanitize 必须加,否则模型输出的 HTML 会被浏览器直接解析,存在 XSS 风险。代码高亮用的是 Shiki,它支持大部分常见语言,而且渲染效果比较好,就是打包体积偏大,我通过按需加载语言包解决。
2.3 移动端适配
现在不少用户会在手机浏览器上打开 AccessAI,所以移动端适配不能忽视。我把布局改成了响应式:屏幕宽度小于 768px 时,左侧会话历史栏默认收起,通过左上角的汉堡按钮呼出;右侧模型配置面板改为底部抽屉样式;输入框固定在底部,适配手机键盘的弹起。
移动端还有一个细节,流式生成的时候屏幕最好不要自动滚动到最底部,否则用户想回头看一眼上面内容会被不断打断。我做了一个判断:只有用户本身已经滚动到接近底部时,新消息才会触发自动滚动;如果用户往上翻了,暂停自动滚动,等用户重新拉到底部再恢复。这个交互改完,手机上的体验提升非常明显。
3. 多模型接入的架构设计
3.1 统一接口层
多模型接入最忌讳的做法是在业务代码里到处写厂商的 SDK 调用。我单独建了一个 providers 目录,每个厂商的适配器都是一个独立的类,继承同一个基类。基类定义了三个必须实现的方法:
python复制class BaseProvider:
async def chat_completion(self, messages, model, parameters): ...
async def stream_completion(self, messages, model, parameters): ...
def count_tokens(self, messages): ...
chat_completion 处理非流式请求,stream_completion 处理流式请求,count_tokens 用来估算 token 数量。这样业务层可以完全屏蔽厂商差异,调用时只需要根据会话里存的 provider 名找到对应的适配器实例。
以 OpenAI 兼容接口为例,适配器内部做的事情其实不复杂:把 AccessAI 统一的消息格式转换成 OpenAI 的 messages 格式,把 temperature、max_tokens 等参数映射过去,然后调用 SDK。但要注意,不同厂商的参数名和取值范围差别不小,比如 Anthropic 的 max_tokens 是必填的,而部分 OpenAI 兼容接口中这个参数是可选的;DeepSeek 不支持 top_p 和 temperature 同时修改,必须要二选一。这些差异都在适配器内部做转换,上层只传一个标准的 parameters 字典。
3.2 各家的差异点处理
我在接入多家模型后整理了这么一张表,供参考:
| 厂商 | 端点格式 | 上下文长度 | 特殊要求 |
|---|---|---|---|
| OpenAI | /v1/chat/completions |
多档可选 | 支持 function calling |
| Anthropic | /v1/messages |
200K 起步 | 消息格式不同,system 单独字段 |
/v1beta/models/...:streamGenerateContent |
按模型区分 | 请求体是 contents 结构 | |
| DeepSeek | /v1/chat/completions |
64K | top_p 与 temperature 互斥 |
| 通义千问 | /v1/chat/completions |
按模型区分 | 兼容 OpenAI 格式 |
这张表的价值在于它能帮你理解为什么不能只套一个 OpenAI 格式。虽然有部分厂商提供了 OpenAI 兼容接口,但 Anthropic 和 Google 的官方接口结构差异很大,如果硬转格式,不仅代码会变得很乱,而且一些厂商独有参数(比如 Claude 的系统提示词单独字段、Gemini 的多模态 content 结构)都无法利用。最稳妥的做法是在统一接口层之上再做一个 provider 抽象,每个厂商单独维护一套适配代码,测试起来也方便。
模型配置文件用一个 JSON 文件维护,里面定义每个模型的 id、显示名称、所属厂商、上下文窗口大小、默认参数。前端从后端拉取这个配置,渲染成下拉列表。新增模型时只需要在配置文件里加一条记录,再写对应的适配器类,无需改动前端页面。
3.3 模型切换时上下文怎么处理
模型切换最让人头疼的是上下文兼容。不同模型上下文窗口大小不一样,比如 GPT-4o mini 和 DeepSeek 可能有 64K 到 128K 的区别,而某些旧模型只有 4K。会话里如果已经积累了一段很长的上下文,切换到小窗口模型时直接发送会触发"超出最大长度"的错误。
我的处理方式是,在切换模型的接口里做一次"上下文裁剪"预检:计算当前会话消息的总 token 数,如果超过目标模型上下文窗口的一定比例(我默认是 80%),就触发截断策略,而不是直接报错。截断策略分两档:第一档丢弃最早的非 system 消息,直到 token 数降到 70% 以内;如果丢弃所有历史仍然超限,就保留 system prompt 和最近的几条消息,其余部分压缩成一段摘要,作为一条 summary 消息放在消息列表开头。这套逻辑保证了切换模型不会白屏报错,同时也尽量保证对话连贯性。
4. 对话上下文与历史管理的实现
4.1 上下文窗口的组织方式
对话上下文不是一个简单的消息数组。我把它拆成三个部分:system prompt、历史消息、当前输入。system prompt 在创建会话时设定,一般描述 AI 的角色和回复规则,它不应该被上下文清理机制误删。历史消息按时间顺序排列,每条消息标记 role(user 或 assistant)和 content。当前输入是用户刚发的这条消息。
组织上下文的核心逻辑是 token 预算控制。在发送请求前,我会先做一次全量估算,把每条消息的 token 数算出来,然后从后往前累加,直到接近上限。这样保留的是最近的对话内容,因为离当前问题越近的消息往往越相关。
token 估算这一块,不同厂商的实现差异很大。OpenAI 系模型用 tiktoken 库,cl100k_base 编码器;Anthropic 没有开放官方计数工具,只能根据字符数估算或者用他们 API 返回的 usage 字段;DeepSeek 和大部分国产模型可以直接复用 tiktoken 的近似结果。为了避免把 token 预估做成一门玄学,我的策略是:能精确计算的用官方库,不能精确计算的用 4 字符约等于 1 token 的经验公式,同时把真实返回的 usage 回写到消息表里,下次估算时优先使用已知值。
4.2 历史会话的数据库设计
历史管理部分要求数据模型能支撑"按会话查消息"和"按关键词搜历史"两个场景。我设计了四张核心表:
sql复制CREATE TABLE users (
id SERIAL PRIMARY KEY,
username VARCHAR(64) UNIQUE NOT NULL,
password_hash VARCHAR(256) NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE conversations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
title VARCHAR(200) DEFAULT '新对话',
provider VARCHAR(50) NOT NULL,
model VARCHAR(100) NOT NULL,
system_prompt TEXT,
status VARCHAR(20) DEFAULT 'active',
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE messages (
id BIGSERIAL PRIMARY KEY,
conversation_id UUID REFERENCES conversations(id) ON DELETE CASCADE,
role VARCHAR(20) NOT NULL,
content TEXT NOT NULL,
prompt_tokens INTEGER DEFAULT 0,
completion_tokens INTEGER DEFAULT 0,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE model_configs (
id SERIAL PRIMARY KEY,
provider VARCHAR(50),
model_name VARCHAR(100),
display_name VARCHAR(100),
context_window INTEGER,
default_params JSONB
);
这里有几个设计决策值得说一下。conversations 表里冗余存储了 provider 和 model,而不是单独建一张关联表,因为一个会话在创建时就确定了使用的模型,之后虽然可以切换,但切换会在会话里留痕。支持切换模型的情况下,我在 conversations 表里加了一个 model_history JSONB 字段,记录每次切换的时间点和模型名,方便用户回看。
消息表用 BIGSERIAL 做主键而不是 UUID,因为消息的并发写入量大,BIGSERIAL 的索引性能更好、存储更紧凑。外键带 ON DELETE CASCADE,删除会话时消息自动跟着删,避免留下孤儿数据。
4.3 会话历史的导出与检索
历史管理不只是把数据存下来,还得让用户能找回之前的内容。AccessAI 提供了三种使用方式:会话列表翻看、关键词搜索、导出。
会话列表通过 updated_at 倒序排列,最近聊的排在最上面,标题默认取第一条用户消息的前 30 个字符,用户也可以手动重命名。关键词搜索用的是 PostgreSQL 的 to_tsvector 和 to_tsquery 全文检索,配合中文分词插件让中文搜索也能用。配置方式是在安装时执行一次扩展创建:
sql复制CREATE EXTENSION IF NOT EXISTS pg_trgm;
CREATE INDEX idx_messages_content_trgm ON messages USING gin (content gin_trgm_ops);
用 trigram 索引做模糊搜索是一种比较轻量的方案,不用引入独立的搜索引擎,数据量在百万条以下时性能完全够用。
导出功能我支持了 Markdown 和 JSON 两种格式。Markdown 格式方便用户直接粘到笔记软件里,JSON 格式保留了完整的元数据,方便做二次处理。实现时在后端拼好文件内容,以附件流的形式返回给前端。
5. 部署与实操过程
5.1 Docker Compose 一键部署
考虑到用户环境各不相同,我提供了两种部署方式:Docker Compose 和本地源码运行。Docker 方式最省事,适合大多数想快速体验的人。项目仓库里有一个 docker-compose.yml,包含三个服务:web 前端、api 后端、postgres 数据库。
yaml复制version: '3.8'
services:
db:
image: postgres:15
environment:
POSTGRES_USER: accessai
POSTGRES_PASSWORD: change-me
POSTGRES_DB: accessai
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U accessai"]
interval: 5s
timeout: 3s
retries: 5
api:
build: ./backend
environment:
DATABASE_URL: postgresql://accessai:change-me@db:5432/accessai
REDIS_URL: redis://redis:6379/0
SECRET_KEY: please-change-this
depends_on:
db:
condition: service_healthy
ports:
- "8000:8000"
web:
build: ./frontend
environment:
VITE_API_BASE_URL: http://localhost:8000
depends_on:
- api
ports:
- "5173:80"
volumes:
pgdata:
部署时第一件事就是把默认密码和 SECRET_KEY 换掉,这个不能省。SECRET_KEY 是用来加密存储的 API Key 的,如果使用默认值,攻击者可以反解出所有用户的密钥。
API Key 的管理方式我做过调整。一开始是明文存在数据库里,后来觉得太不安全,改成用 Fernet 对称加密后再存储,解密密钥就是环境变量里的 SECRET_KEY。这样即使数据库被拖走,拿到的也是密文。密钥的配置支持两个层级:全局默认密钥(所有用户共享)和用户级密钥。用户在自己账号下配置的密钥优先于全局密钥,这样每个人可以用自己的账号,也不用担心把自己的 key 暴露给别人。
5.2 环境变量与初始化配置
首次启动后,需要访问 http://localhost:8000/docs 确认 API 服务起来了,然后访问前端页面注册管理员账号。管理员账号通过后端的种子脚本创建,默认有权限查看系统级配置。在系统设置里,你需要填上模型服务商的 API Key,以及默认使用的模型名。
配置项里最重要的三个是模型连接参数、上下文窗口和请求超时时间。模型连接参数包括 base URL、api key、模型名称,这部分根据你用的是哪个厂商填就行。上下文窗口需要和你使用模型的实际窗口一致,默认我给了 8192,但这个值设得太大会导致预估不准,设得太小会频繁触发截断逻辑,建议按模型实际情况来。请求超时时间默认 120 秒,如果模型响应慢,可能不够,需要适当调大。
如果你是通过源码运行,需要手动安装后端依赖:
bash复制cd backend
python -m venv venv
source venv/bin/activate
pip install -r requirements.txt
uvicorn app.main:app --host 0.0.0.0 --port 8000
前端则需要:
bash复制cd frontend
npm install
npm run dev
前端开发服务器默认在 5173 端口,后端在 8000 端口,开发模式下需要配置 Vite 的代理,把 /api 请求转发到后端的 8000 端口,避免开发环境出现跨域问题。
5.3 常见问题与排查技巧
我在测试和用户反馈中收集了不少问题,整理成一张排查表,基本覆盖了常见的坑:
| 现象 | 可能原因 | 排查与解法 |
|---|---|---|
| 请求返回 401 | API Key 配置错误或已过期 | 检查系统设置里的密钥,到对应厂商后台校验额度 |
| 请求返回 404 | base URL 填错 | OpenAI 系一般是 https://api.openai.com/v1,不要把 /v1 重复写 |
| 提示超出上下文长度 | 上下文裁剪未生效 | 检查目标模型的 context_window 是否配置正确 |
| 流式输出断断续续 | 网络代理或超时设置过短 | 调大请求超时时间,检查网络到厂商端点的连通性 |
| 中文聊天记录搜索不到 | 未安装分词插件 | 执行 CREATE EXTENSION pg_trgm,重建索引 |
| 切换模型后回复错乱 | 会话上下文未清理干净 | 检查消息表里是否有旧的 system prompt 残留 |
| 部署后前端白屏 | 后端 API 地址配置错误 | 查看浏览器 Network 面板,检查 /api 请求是否 404 |
| 历史记录莫名其妙丢失 | 数据库 volume 未挂载 | 确认 docker-compose 里 db 服务配置了 volumes |
这里我特别想提醒一点:调试多模型接入时,最好先各家单独建一个新会话测试,确认连通性之后再做会话切换测试。一来可以加快定位问题,二来避免多个变量混在一起难以排查。我自己就遇到过这种情况,OpenAI 和 DeepSeek 在各自会话里都正常,切换模型后却报错,排查半天发现是会话对象里的 system prompt 被写成了 OpenAI 格式,而 DeepSeek 兼容模式解析不了这种结构。
另一个常见坑是 redis 连接失败导致流式输出中断。AccessAI 把 SSE 中间状态放到了 Redis,如果 Redis 没起或者网络不通,前端的流会卡在半路。排查时留意一下 redis 容器日志,不要忽略这种基础设施层面的问题。
6. 聊聊我自己踩过的坑和后续想法
这次更新最大的收获不是代码量多了多少,而是我意识到做这类工具,最关键的设计决策其实发生在写代码之前。比如上下文管理,很多人会一上来就写"把消息全部发给模型",但等到模型窗口撑爆、切模型失效的时候才想到要做裁剪和摘要。又比如历史管理,如果一开始表结构设计不合理,后面加搜索功能会非常痛苦,索引没法建、查询慢,只能推倒重来。
如果这个项目后续继续发展,我下一步想做的方向有三个:一是把消息表做分区,按日期或会话 ID 水平拆分,这样历史数据增长再多查询也不会明显变慢;二是增加更多的模型厂商适配,尤其是国内一些小型模型平台;三是把对话上下文的摘要能力做得更聪明一些,不只是简单截断,而是能够按主题保留和压缩信息。
如果你在搭建类似项目,我的建议是:先花时间把消息上下文的组织方式和数据库表结构设计好,再考虑界面。界面什么时候都能改,但数据模型定错了,后面每一步都得为它买单。AccessAI 的代码已经全部开源在仓库里,你感兴趣可以直接拉下来跑跑看,有问题也欢迎去提 issue 交流。
