我用了一整天在浏览器里开十几个标签页跟 Claude 来回周旋之后,终于下决心做了这么一件事:自己写一个轻量级 Claude 桌面客户端。它支持自定义 API、联网搜索、图片与文档对话,基本上把我在日常使用中最在意的几个能力全部收进了一个原生窗口里。这篇文章不是产品发布会,就是一次真实的项目复盘——从需求思考、技术选型到实现中的每一个坑,我都会尽量还原,方便你参考或者直接动手改造一个自己的版本。
先说清楚这个东西解决了什么问题。官方网页版其实做得已经不错,但只要你的工作流稍微复杂一点——比如一边查资料一边跟模型讨论、需要把本地 PDF 拖进对话、或者想用自己账号的 API 而不想多付一份订阅费——网页端就会变得很别扭。这个项目解决的核心痛点有三个:第一,把 Claude 变成一等公民一样的本地桌面应用,常驻、秒开、不掉上下文;第二,让 API 地址、模型、参数全部可配置,你的客户端你说了算;第三,把联网搜索和多模态内容(图片、PDF、Word 等)统一塞进对话流程,不再需要手动复制粘贴。
这篇文章适合谁看?如果你只是 Claude 的使用者,想找一个更顺手的桌面前端,可以看思路;如果你恰好是开发者,想用 Tauri 或者 Electron 给自己的 AI 工作流做一个专用客户端,那核心代码逻辑和踩坑记录应该能帮你省掉不少摸索时间。我不会只丢结论,尽量把每个选择背后的“为什么”也讲清楚。
1. 需求拆解:什么样的桌面客户端才算“值得做”
动手之前我给自己出了三道题:现在用的工具哪里不顺手?做出来之后会不会比现状好 30% 以上?这个工作量我能不能在一个可控的周期内完成?如果答案不通过,就说明不该做。前两题很快就过了,真正花时间的是第三题。
1.1 核心需求清单:把“我想用”翻译成“要做什么”
在一个周末的下午,我把自己的使用习惯列成了一张表:
- 常驻可用:不用每次打开浏览器、找回书签、重新登录,最好开机就能唤起。
- 轻量体感:启动速度要在 1 秒以内,内存占用不超过 200MB 是底线,不能像个 IDE 一样吃资源。
- 自定义 API:能填自己的 Base URL、API Key、模型名称,支持 OpenAI 兼容格式的网关,这样以后换模型不用换软件。
- 联网搜索:让模型在需要实时信息的时候能自己去查,而不是一本正经地告诉你“我的知识截止到 XX 时间”。
- 图片与文档对话:PDF、Word、TXT、PNG、JPG 拖进窗口就能进入上下文,模型能读内容、能看图。
- 会话管理:支持多会话、历史记录、导出,不能每次关掉窗口就“失忆”。
这些需求看起来不多,但每一条背后都有一堆实现细节。比如“自定义 API”不是让你填个 URL 就完事,还要考虑鉴权方式、模型参数映射、流式输出、错误状态码,甚至有些网关的响应格式和 Anthropic 原生格式不一样。再比如“图片与文档对话”,PDF 要先解析成文本,图片要考虑压缩和编码,token 怎么估、超长怎么截断,这些不在真正动手时是意识不到的。
1.2 为什么没有直接拿现成的开源项目来改
我第一个念头确实是上网搜一圈,看看有没有已经做好的 Claude 桌面客户端。搜到的结果大概有三类:一类是官方 Claude Desktop,功能虽然全,但 API 地址和模型绑定得很死,我没法接自己想用的服务;第二类是开发者做的 ChatBox、NextChat 这类聊天前端,它们对 OpenAI 生态支持很好,但针对 Claude 的多模态消息格式和联网工具集成,总感觉是“兼容”而非“原生”;第三类是一些命令行的 Claude Code,适合终端用户,但图形界面的需求还是没被覆盖。
还有一个更现实的原因:我希望这个客户端的体积足够小、启动足够快。很多全功能聊天前端基于 Electron 做的,打包出来动不动就一两百 MB,内存随便占个 400MB。我想要的是一个纯粹的、专注的、只为 Claude 设计的小工具。考虑到这些,用 Tauri 2 自己做一个反而比改一个重型项目更省事。
1.3 “轻量级”不是个形容词,是可量化的指标
我在项目一开始就把“轻量级”定义成了三个可验证的数据指标,避免做着做着变成一个功能堆砌的大怪物:
- 安装包控制在 15MB 以内,绿色免安装最好。
- 应用常驻内存控制在 100MB 附近,具体以 Tauri 的 WebView 进程实际占用为准。
- 从冷启动到可以输入第一句对话,时间不超过 1 秒。
这三个指标,用 Electron 很难同时做到,用 Tauri 2 则很自然。事实证明最终打包出来的产物只有 10MB 左右,启动体感是“瞬间”,常驻内存大概 70–90MB。这个结果让我很满意。
2. 技术选型与整体架构:Tauri 2 为什么是这次的最优解
技术选型这件事,没有绝对的正确,只有适合不适合。我的场景是:需要原生窗口体验、低内存、跨平台,并且我要用 Rust 和 TypeScript 这种我比较熟练的栈。Tauri 2 在这几个维度上完美踩中。
2.1 Electron 和 Tauri 实测数据对比
为了让自己心里有底,我把 Electron 和 Tauri 的最小 demo 各跑了一遍,记录下关键数据:
| 指标 | Electron 最小示例 | Tauri 2 最小示例 |
|---|---|---|
| 安装包体积 | 约 70MB 起步 | 约 3–5MB 起步 |
| 常驻内存 | 约 200MB 起步 | 约 30–50MB |
| 冷启动到窗口可见 | 1–2 秒 | 200–500ms |
| 前端栈 | Chromium + Node.js | 系统 WebView + Rust |
虽然这些数据会随着功能增多而变化,但趋势很明显:Tauri 在“轻量”这个目标上直接胜出。代价也很明显——你需要会一点 Rust,因为调用系统能力、文件处理、凭据管理等都要写在 Rust 侧;如果你完全不会 Rust,学习曲线会比 Electron 陡一些。但如果你愿意花一两天补一下基础,Tauri 的收益是值得的。
2.2 前后端分离:Rust 处理后端,WebView 处理界面
架构上我并没有搞得很玄乎,核心思路是:所有跟模型 API 的通信、文件解析、密钥保存等“重活”都交给 Rust 后端,前端只负责展示聊天界面和收集用户输入。
这样的好处很实际:
- Rust 侧发 HTTP 请求可以避开浏览器的 CORS 限制,不用为 API 跨域问题头疼。
- API Key 可以存在系统密钥管理器里,而不是放在 localStorage,安全性高一个档次。
- 文件解析(PDF、DOCX)用 Rust 的库来做,性能比在 JS 里跑好很多,而且不阻塞 UI。
- 前端代码更简单,遇到 UI 问题也好排查。
Frontend (React + TypeScript) ↓ Tauri invoke / event Backend (Rust) ↓ reqwest Anthropic API / OpenAI-compatible API / Search API前后端之间的通信,Tauri 提供了invoke(请求-响应)和emit/listen(事件)。我大部分场景用invoke,只有流式输出时用事件从前端订阅,原因后面流水线部分会解释。
2.3 核心数据流:一场对话是怎么走完整个链路的
一次普通对话的生命周期大致是这样的:
- 用户输入文本,前端把消息追加到当前会话。
- 如果开了联网搜索,前端先调用后端
search_relevant,触发搜索流程,拿到一批网页摘要。 - 前端把用户消息、搜索结果摘要、当前会话的历史消息一起组装成请求体,然后调用后端
chat_completion。 - 后端把这个请求转发给你配置的 API 地址,拿到流式响应。
- 后端每收到一小段增量,就通过事件
on_chunk推给前端;前端一边渲染一边滚动。 - 全部结束后,后端把完整会话写入历史存储文件。
这个流程里,联网搜索是一个可选的预处理步骤,不会干扰主对话。图片和文档则是通过“文件预处理”阶段把它们转换成文本或图片消息,再进入第 3 步的正常拼接。把流程拆成这样之后,每个环节都能单独测试、单独升级,后续加功能也不容易把主链路搞坏。
3. 自定义 API 接入:远不止改一个 Base URL 那么简单
标题里说“支持自定义 API”,很多人第一反应是:哦,设置里加两个输入框呗。但真实情况远比这个复杂。API 的鉴权方式、请求体结构、流式传输格式、错误码语义,不同服务商之间差异很大。这一章我会把我踩过的坑和最终的处理方式完整写出来。
3.1 Anthropic 原生 Messages API 的调用格式
Anthropic 官方 API 的基础格式是这样的:
curl https://api.anthropic.com/v1/messages \ --header "x-api-key: $ANTHROPIC_API_KEY" \ --header "anthropic-version: 2023-06-01" \ --header "content-type: application/json" \ --data '{ "model": "claude-sonnet-4-20250514", "max_tokens": 4096, "messages": [ {"role": "user", "content": "请介绍一下云原生架构的核心概念"} ] }'注意几个容易忽略的细节:
- Header 里面必须有
anthropic-version,不写会被拒。 max_tokens是必填字段,不能省略,而且它指的是“本次回复的最大 token 数”,不是“上下文总长度”。- 多模态图片的格式是把 content 变成一个数组,其中一项是
image类型,后面我会详细讲。 - 模型名称每天都在变,所以客户端里模型名必须做成可编辑的,不能写死在代码里。
3.2 流式输出:SSE 解析的正确姿势
我用 Tauri 的 Rust 后端来做流式请求,关键点是:不要把整个 HTTP 响应等完再返回,要按行读取 Server-Sent Events(SSE)数据,边读边转发给前端。
SSE 的消息长这样:
event: message_start data: {"type":"message_start","message":{...}} event: content_block_delta data: {"type":"content_block_delta","delta":{"type":"text_delta","text":"你好"}} event: message_stop data: {"type":"message_stop"}我的后端核心逻辑大致如下:
while let Some(chunk) = stream.chunk().await? { let text = String::from_utf8_lossy(&chunk).to_string(); // 遇到 `data: ` 开头且以 JSON 结尾的行,解析出 delta 文本 if let Some(json_str) = extract_data_field(&text) { if let Ok(parsed) = serde_json::from_str::<DeltaPayload>(json_str) { if let Some(delta_text) = parsed.extract_text() { app_handle.emit("on_chunk", delta_text)?; } } } }前端用 Tauri 的listen接收on_chunk,每收到一段就追加到消息气泡里。这里有一个很关键的体验优化:页面必须用虚拟滚动,否则对话一长,每来一个 chunk 都触发全量渲染,UI 会明显卡顿。
3.3 OpenAI 兼容网关:把两套消息结构互相转换
很多代理服务或自建网关并不是 Anthropic 原生格式,而是 OpenAI 兼容格式。为了让自定义 API 支持面更广,我加了一层“协议适配”。OpenAI 的 Chat Completions 消息结构长这样:
{ "model": "gpt-4o", "messages": [ {"role": "system", "content": "你是 Claude 客户端,但你现在的底座是用户指定的任意模型。"}, {"role": "user", "content": "你好"} ], "stream": true }而 Anthropic 的结构把 system 单独拎出来,消息里的图片又是 content 数组。所以转换逻辑要处理三件事:
messages里 role 为system的项要拆出来,变成顶层system字段。- 多模态的 content 数组要还原成 OpenAI 风格的
image_url或text内容。 - 响应里的
content_block_delta要映射为 OpenAI 的choices[].delta.content。
转换不复杂,但容易出 bug。我的经验是最好在 Rust 里写两个独立的 serializer,并用单元测试把两边的样例都覆盖一遍,别在前端 JS 里做这种容易漏的场景。
3.4 密钥管理:别把 Key 放 localStorage
API Key 的保存方式,我一开始图省事直接放在前端 localStorage,后来立刻否掉了——只要页面被 XSS 打到一次,Key 就没了,这在桌面应用里是不可接受的低级错误。最终方案是:
// Rust 后端,用 keyring 调系统凭据管理器 let entry = keyring::Entry::new("com.mydesktop.claude", "api_key")?; entry.set_password(&api_key)?; // 读取 let stored_key = entry.get_password()?;在 macOS 上是钥匙串,在 Windows 上是凭据管理器,在 Linux 上会根据桌面环境选择 libsecret 或 kwallet。这个方案比把 Key 明文存在配置文件里要安全得多,而且对用户来说几乎没有感知。
3.5 实测中一定会遇到的错误码清单
我整理了一份实测时最常见的 HTTP 错误和应对策略,建议你直接把这段逻辑抄进自己的错误处理里:
| 场景 | 状态码/提示 | 处理方式 |
|---|---|---|
| API Key 无效 | 401authentication_error | 弹窗提示重新填写密钥,不清空已有会话 |
| 密钥所在组织被封禁 | 400this organization has been disabled | 明确提示账号问题,建议检查后台 |
| 请求超频 | 429rate_limit_error | 自动等待一段时间并重试,退避策略用Retry-After |
| 上下文超长 | 400maximum context length is 1048576 tokens | 自动回滚历史消息列表,把最早的几条删掉或摘要,再重发 |
| 模型名不存在 | 404model not found | 提示检查模型名,给出可用模型列表 |
| 网络连接被重置 | ECONNRESET/connection dropped | 自动重试 1 次,如果还失败则提示检查网络 |
特别注意那个 “maximum context length is 1048576 tokens” 的错误,它出现在我接一些上下文窗口特别大的模型时。本来 100 万 token 已经很大,但如果你把一个 5 万行的日志文件直接丢进去,照样超限。后面我会专门讲上下文截断策略。
4. 联网搜索实现:从意图判断到结果回填的完整链路
联网搜索是用户非常敏感的功能,也是不少项目糊弄的地方——搜索结果是一回事,模型能不能有效使用这些结果是另一回事。我这里做了一条完整的链路,实测下来效果比简单把几段网页文字塞进去好很多。
4.1 为什么必须做两层:意图判断和查询词重写
如果用户的输入是“帮我写一段 Python 快速排序”,根本不需要联网。但是“帮我查一下 2025 年 6 月发布的显卡型号”,就必须联网。如果每句话都触发搜索,浪费 token,响应也慢。
所以我的设计是:默认关掉自动搜索,用户可以手动点击“联网搜索”按钮;同时提供一个“智能模式”,让模型先判断是否要搜索。智能模式的实现不是偷偷调一次 API,而是在后端做一个轻量的本地判断:
- 用户消息中包含年份、最新、通知、新闻、价格、对比等词。
- 消息包含具体网址或产品型号。
- 系统提示词明确要求模型标注“需要实时信息”。
满足这些条件之一,就触发搜索。这个规则引擎在绝大多数场景已经够用,而且零额外延迟。说实话,有些人会直接用模型判断是否需要搜索,但那要浪费几百 token 和一次完整请求,我不太喜欢。
智能判断通过后,第二步是查询词重写。用户的原话可能是“现在的旗舰手机有什么推荐”,但搜索 API 对口语化长句的效果不太好。我会把这句话重写成几个短查询词,比如“2025 旗舰手机推荐 评测”。这个重写我用的是一个极简的本地算法——提取关键词、过滤停用词、按名词和形容词排序。效果不错,而且完全没有额外模型调用成本。
4.2 搜索 API 的选择与切换
搜开放平台有很多种搜索服务可用,我主要集成了普通的 Web Search API,另外做了一个方便替换的 trait。
pub trait WebSearcher { async fn search(&self, query: &str, limit: usize) -> Result<Vec<SearchResult>, SearchError>; }这样以后想切别的搜索服务,只需要新写一个实现,不用动任何业务代码。我实测的几个搜索结果质量排序是:综合性搜索平台 > 独立的学术搜索 > 免费的公开搜索源。免费的公开源虽然够用,但结果时效性和去重质量都比较一般。
4.3 搜索结果如何转成模型能看懂的文本块
搜索 API 返回的结果通常是标题、链接、摘要、正文片段。这里有个大坑:直接把所有结果的摘要拼起来塞进上下文,效果一塌糊涂。模型会分不清哪些是检索来的事实、哪些是评论、哪些是广告。
我的做法是构造一个“搜索引擎上下文块”,放在最开头,并且明确声明这是外部检索结果:
[联网搜索结果] 查询词:2025 旗舰手机推荐 评测 1. 标题:XX 手机评测 来源:example.com 发布时间:2025-06-01 摘要:... 2. ... [搜索结束] 请基于以上检索结果回答用户问题。 优先使用最新发布时间的信息。如果检索结果与你的内部知识冲突,以检索结果为准,但注明信息发布日期。这样的好处是,模型能感知每一条信息的时效性和来源,回答时会自然地带上“根据 2025 年 6 月的搜索结果”这类表述,而不是把新知识悄悄混进旧的内部记忆里。
4.4 Token 估算和上下文预算
搜索结果塞多少进来,不能拍脑袋。我的预设是:给检索结果预留整个上下文窗口的 20%(如果窗口是 200K tokens,那就是 40K),但搜索结果总文本不超过 5000 字左右,多余的直接截断。
fn truncate_search_results(results: &[SearchResult], max_chars: usize) -> Vec<SearchResult> { let mut budget = max_chars; let mut selected = vec![]; for r in results { let text_len = r.title.len() + r.snippet.len(); if text_len > budget { break; } selected.push(r.clone()); budget -= text_len; } selected }这个策略很朴素,但很有效。不要让检索结果挤占用户指令和历史的 token 预算,模型一旦“忘了用户说了什么”,那搜索就白做了。
4.5 实测效果和一些说明
做个对比吧:
- 不联网:问“Claude 最新发布的模型是哪一款”,模型会基于训练数据猜测,最后加一句“我的信息可能不是最新的”。
- 普通联网:模型能答出最新模型名,但没有型号细节、没有时间线。
- 本项目的完整链路:模型会列出发布时间、主要能力变化、还在回答里附上参考来源的标题和链接,准确度明显提升。
缺点也坦白说:搜索有时候会把多个相似结果都塞进来,占掉不少上下文,而且如果搜索 API 本身返回的内容很水,模型也救不回来。所以后续我在考虑对搜索结果做一个简单的相关性排序,把明显不相关的条目在注入前就过滤掉。
5. 图片与文档对话:把本地文件变成模型能理解的上下文
“图片与文档对话”是标题里相对吸引眼球的功能,也是实际使用频率最高的功能之一。我处理文档的方案经历了两次迭代,从最简单的纯文本抽取,到后来加了版式还原,中间踩了不少坑。
5.1 图片消息:Base64 还是图片 URL
Anthropic 的 Messages API 原生支持图片内容,要求是传入图片的 base64 编码数据和 MIME 类型。格式大致是这样的:
{ "role": "user", "content": [ { "type": "image", "source": { "type": "base64", "media_type": "image/png", "data": "/9j/4AAQSkZJRg..." } }, { "type": "text", "text": "这张图里有什么?" } ] }在 Tauri 里读取图片文件后转 base64 很简单,但我遇到一个实际问题:有些截图文件非常大,base64 之后动辄十几 MB,直接塞进 API 请求会导致请求体巨大,而且网速慢的时候体验极差。
我的处理是:
- 判断图片尺寸,超过 1568px 的先用 Rust 的
image库缩到合适大小。 - 把图片转成 JPEG(除非原图有透明通道才保留 PNG),质量设为 85。
- 限制单张图片压缩后的 base64 不超过 1.5MB,超过就继续降质量。
原因很简单:Anthropic 的视觉模型对图像分辨率有推荐上限,太高不会显著提升理解效果,反而浪费请求和计费。实测下来,普通截图压缩到 1568px 宽度以内,模型看文字、看图表、读 UI 都完全没问题。
5.2 文档解析:PDF、Word、Markdown、TXT 各显神通
文档对话的链路比图片复杂,因为 PDF 和 Word 是二进制格式,不能直接喂给模型。我的思路是先解析成 Markdown 或纯文本,再作为文本块进入消息上下文。
文档类型和推荐解析方案我对比了一圈:
| 文档类型 | 推荐方案 | 说明 |
|---|---|---|
pdftotext(poppler 工具)/ MinerU | 文字版 PDF 两者均可;扫描版必须 OCR | |
| DOCX | docx-rs或 pandoc | 推荐转成 Markdown,保留标题层级 |
| XLSX | 单独解析为表格文本 | 简单转 CSV 即可,模型读 CSV 很轻松 |
| Markdown/TXT | 直接读 | 不做任何转换,保留结构化信息 |
这里重点说 MinerU。MinerU 是一款开源文档解析工具,能把 PDF 解析成带排版结构的 Markdown,对数学公式、表格、多栏版式的还原度非常惊艳。如果你的文档里经常有复杂的学术排版,建议优先考虑这类结构化解析工具,而不是纯pdftotext抽文本。
# 示例:用 MinerU 命令行解析一个 PDF mineru -p input.pdf -o output_dir不过要注意,MinerU 这类解析工具往往需要安装额外的模型权重,首次运行会下载模型,如果你只是处理三五页的普通文档,用它有点牛刀杀鸡;但处理两百页的行业报告时,它的价值就出来了。我的客户端默认用轻量解析,并且提供“高质量解析”选项,用户遇到复杂文档时可以手动切换。
5.3 超长文档怎么办:先切块、再摘要、后选择
我刚开始做文档对话时犯了个错误:把整个 PDF 全文一股脑塞进上下文。因为大部分上下文窗口虽然大,但 500 页 PDF 还是可能直接顶穿,而且模型的理解质量会随着上下文长度增长而下降。
后来我改成“切块—向量化—选择相关块”的经典 RAG 思路,但做了一些简化:
- 解析出全文后,按固定大小切块(比如每块 1500 tokens,带 150 tokens 重叠)。
- 对每个块做文本 Embedding,存在内存向量库中。
- 用户提问后,用用户问题的 Embedding 跟每个块做余弦相似度计算。
- 取最相关的 3–5 个块拼入上下文,附上每个块的来源页码。
// 简化的相似度选择逻辑 fn select_relevant_chunks( query_vec: &[f32], chunks: &[(String, Vec<f32>, usize)], // (text, vector, page) top_k: usize, ) -> Vec<&(String, Vec<f32>, usize)> { let mut scored: Vec<(f32, usize)> = chunks .iter() .enumerate() .map(|(idx, (_, vec, _))| (cosine_similarity(query_vec, vec), idx)) .collect(); scored.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap()); scored .into_iter() .take(top_k) .map(|(_, idx)| &chunks[idx]) .collect() }这套方案虽然比“直接全文塞入”复杂一些,但好处确实值得:
- 上下文占用稳定,不会随着文档页数增长而崩掉。
- 回答更精准,因为模型只关注与问题相关的段落,而不是被无关内容干扰。
- 可以正确回答“这个观点在文档的哪一页”这类定位问题,因为每个块都保留了页码。
5.4 拖拽交互与文件预处理的后台机制
交互上,我支持把文件直接拖进聊天输入区,或者点击回形针按钮选择文件。被拖进去的文件在发送之前先进入一个“预处理队列”,前端显示缩略图和状态:
- 图片:显示压缩后的预览。
- PDF/Word:显示解析进度百分比,解析完成会生成一个可点击的“已解析文档”标签。
- TXT/Markdown:直接显示文本预览,并提供截断开关。
这个预处理过程放在 Rust 后端跑,所以不会卡住聊天界面。用户在等待解析的时候还可以继续打字,等文件状态变成“可用”再点击发送。这个细节让我在使用时非常舒服——之前用别的工具,拖个大 PDF 进去往往整个界面转圈,你什么都不能干。
6. 实现中踩过的坑和这次最有价值的优化
最后写点我实际开发过程中的“血泪史”。这部分的经验可能比前面的功能描述更值钱,因为每一个坑都花过真实时间。
6.1 坑:SSE 数据用 invoke 返回导致 UI 卡死
第一版做流式输出时,我把整个流式响应在 Rust 里收集完,然后用一次invoke返回给前端。结果就是,输入一句长回复,界面上要等好几秒才“突然”全出来,中间没有任何反馈,真正意义上的“假死”。
后来改成事件流,前端每收到一个on_chunk就增量追加渲染文本,体验马上丝滑起来。核心改动就三处:
- Rust 侧把
invoke换成emit。 - 前端用
listen('on_chunk', callback)接收。 - 前端把它用
requestAnimationFrame节流,避免每 20ms 一次高频 chunk 触发整块 DOM 重绘。
6.2 坑:Rust 请求客户端没有自动处理 gzip 和重定向
用reqwest默认配置请求第三方 API,第一次请求就踩了坑:有些 API 网关会返回301/302重定向,默认不跟随的话请求直接失败;还有的响应是 gzip 压缩的,忘了开gzipfeature,中文会乱码。
修复很简单:
let client = reqwest::Client::builder() .gzip(true) .redirect(reqwest::redirect::Policy::limited(5)) .timeout(Duration::from_secs(120)) .build()?;建议你在任何 AI 客户端项目里都加上 gzip 和重定向支持,省掉后续一长串排查时间。
6.3 坑:并发请求时的顺序错乱
一次搜索流程中,模型可能要并行调用多个工具,比如同时搜索两个关键词,然后等待结果一起返回。如果每个请求都起一个线程去更新同一个“会话状态”,会出现消息顺序错乱。
我的解决方案是引入一个简单的“会话锁”:任何一个工具调用在修改会话上下文前,先拿到一把锁,按调用顺序写入结果。虽然损失了一点并发收益,但换来了上下文的稳定性。对于桌面客户端来说,稳定比并发量重要得多。
6.4 会话历史的存储方案
会话历史我用 JSONL 格式存储,每条消息一行。比如:
{"role":"user","content":"帮我查一下今天天气","timestamp":"2025-06-01T10:00:00Z"} {"role":"assistant","content":"好的...","timestamp":"2025-06-01T10:00:05Z"}为什么不用 SQLite?这个项目的核心目标之一是轻量,JSONL 文件不需要初始化、不需要迁移,读起来也方便。等到会话特别多时再考虑迁到 SQLite 也不迟。不过我现在加了一个简单的文件锁,防止两个会话同时写同一个文件造成损坏。
6.5 一些我真实想吐槽的点
做完了这个客户端之后,我对“桌面端 AI 应用”这件事有了更实际的感受:
- 真正难的不是调 API,而是管理上下文生命周期。你永远要担心用户会不会把一个 50MB 的文件拖进来,会不会连续聊 3 小时把上下文顶爆。
- 模型强不强是 API 决定的,但“好不好用”是客户端体验决定的。同样的模型,在一个频繁转圈、上下文静默丢失的客户端里和一个果断截断、引用来源的客户端里,使用感受天差地别。
- 别为了“功能多”牺牲“简单的快乐”。这个客户端我最常用的功能其实就三个:快速唤起、联网搜索、拖文件对话。其他花哨功能可以以后再说。
根据我个人经验,如果你也想搭一个类似的 AI 桌面客户端,我建议先做最小闭环:一个输入框、一个流式输出、一个 API Key 配置框,把这三个跑通之后再往上加搜索和多模态。不要一上来就铺很大的架构,因为你会发现,真正让你满意的地方往往不是功能堆砌,而是那些不起眼的细节——拖进去的 PDF 能显示解析进度、搜索的结果能标明来源、断网的时候能优雅提示重试。这些小细节,才是把一个“能跑的 demo”变成一个“我每天都愿意用”的工具的关键。