给搜索引擎一张脸:DeepSeek 搜索前端开发手记
一、引子:能力与面孔之间
搜索引擎是互联网最古老的产品形态。用户对它的期待早已凝固成一个动作:一个空框,一次回车,然后世界展开。这几乎成了集体无意识——以至于当一家公司把自研搜索引擎包装成 API 出售时,你拿到的只是两个端点:/v1/tools/search 返回带权威分与重排分数的结果列表,/v1/tools/fetch 把任意网页转成 Markdown。能力齐备,但没有人能直接使用它。
DeepSeek 的搜索 API 就是这样一个"有身体、没有面孔"的东西。它按成功请求计费,¥5 一千次;它返回标题、链接、摘要、日期、authority、rerank_score——数据丰富得像一份体检报告。可如果没有一张脸,它就永远只是管道工人的工具。
这个项目的全部野心,一句话就能说尽:**给能力一张脸。**而这次开发真正教给我的,是面孔之下的事情——状态住在哪里,沉默如何说话,克制为什么是一种美德。
二、骨架:抄 Google 的作业,抄它的克制
界面从第一天起就决定对齐 Google。不是出于懒惰,而是出于敬畏——Google 搜索页是过去二十年被迭代得最狠的界面,它身上每一像素都有存在的理由:logo 居中,因为视线需要锚点;搜索框大而不粗,因为它是唯一的主角;按钮只有两个,因为选择是要收费的,每个多余的按钮都在向用户征收注意力税。
照着这个骨架,我长出了自己的肌肉:
首页近乎极简:logo、搜索框、三个按钮(搜索、手气不错、搜索设置),加一排"大家都在搜"的热点词。深色模式跟随系统又可手动切换,背景铺一层若有若无的品牌色光晕——像 Google 的白,但有一点自己的体温。输入时实时联想,回车前答案已经成形。
结果页则是信息密度的世界:顶栏收纳一个紧凑搜索框,下方五个标签页——全部、摘要、聚合、抓取、语法。"全部"按相关性、权威性、时效性综合排序;"聚合"把同一域名的结果折叠成站点卡片;"抓取"把任意 URL 转成 Markdown 直接阅读;"语法"页教人使用 site:、lang: 这样的高级玩法。每条结果都亮出真实的排序依据:authority 分、rerank 分、日期。我坚持把分数露出来,因为信任不来自"我们排好了",而来自"你能看见我们怎么排"。
这是第一课:**借鉴不是抄袭布局,是继承纪律。**Google 的克制是可以被抄走的——它藏在"按钮为什么只有两个"这个问题被认真回答过的每一个细节里。
信息架构上我还守着一条原则:**按需披露。**普通用户只需要一个框和一排结果,所以高级能力全部后撤:排序分数要凑近才看清,抓取和语法藏在第二个标签页,站点聚合默认折叠,搜索设置收在弹层里。界面不炫耀它的全部本领,像一位不主动报菜名的厨师——能力在场,但只在被需要时现身。复杂度不会消失,只能被安排到看不见的地方,而"安排复杂度"正是前端的本职工作。
三、双身体:一具在本地,一具在边缘
前后端分离是这个项目最自然的形态,但"分离"很快演变成一个更有趣的决定:同一套后端逻辑,写了两遍。
第一具身体是 server.js:一个零依赖的 Node http 服务,监听 127.0.0.1:8899,由 launchd 守护。它负责注入密钥、合并请求、重试、缓存,延迟以毫秒计,只属于我的书桌。
第二具身体是 worker.js:同样的逻辑,翻译成 Cloudflare Worker 的方言——内存缓存换成 isolate 内的 LRU 加 Cache API,密钥换成 Worker Secret,运行在世界边缘的某个机房里。它被部署在 ds.want.biz,前面罩着 Cloudflare Access,未登录的访客会被 302 到一个身份页——这张脸只对认识的人笑。
同一个应用,从此有了两具身体。本地的它住在进程里,边缘的它在每次请求时短暂醒来又熄灭。Cloudflare 的 isolate 不保证常驻,冷启动几毫秒,记忆随请求结束而蒸发——边缘的存在方式近乎佛教的刹那生灭:没有永恒的进程,只有一次次当下的响应。状态住在哪里,服务就活在哪里,这是无服务时代最朴素的形而上学。
为什么容忍逻辑写两遍?因为两具身体的诉求不同。本地版要的是密钥解析链:环境变量、.env、本地配置、skill 兜底,四级回退,总能找到一把能用的钥匙;边缘版要的是无状态与冷启动速度。共享抽象可以消除重复,但也会把两具身体捆在同一副骨架上。重复是独立性的价格,而独立,有时比 DRY 更值钱。
当然,这个决定留了一道疤:两份代码会漂移。我后来确实发现 server.js 的重试分支里漏了一个 clearTimeout,而 worker.js 里有。疤不大,但它每天提醒我:每一次选择重复,都是向未来的自己借一笔债。
四、仪式与守护:让服务活着是一门手艺
写代码只完成了一半,让它在时间里持续活着,是另一半。
本地服务的生命周期交给 launchd:一份 plist,RunAtLoad 开机自启,KeepAlive 崩溃重生,日志安静地落在 ~/Library/Logs 下。曾有一次我把项目文件夹挪了个位置,plist 里写死的路径瞬间作废,服务无声死去——守护进程的忠诚是绝对的,它只忠诚于你写下的路径,而不是你的意图。修好它花了十分钟,理解它花了更久:自动化不会替你思考,它只是放大你的假设。
线上则是一条部署流水线:wrangler deploy,十几秒后新版本在全球边缘生效。密钥用 wrangler secret put 注入,永不落盘、永不进 git;计费 API 前面还没有限流——这是我记账本上未还的又一笔。
缓存是另一场与时间的交易。同一查询的结果缓存五分钟:这是一个赌注,赌世界在这五分钟里不会变得面目全非。搜索是最刁钻的缓存对象,因为它天然想做"此刻",而"此刻"永远在过期。五分钟是我在新鲜与代价之间画下的一条线——所有工程参数,本质上都是这样的线。
项目的迁移也属于这门手艺。代码最初散落在临时目录里,后来搬进正式的 git 仓库,正本归位,README 里写下"此为正本"与日常运维命令。给项目上个户籍,看似形式主义,实则是给未来的协作者(多半是三个月后失忆的自己)留一张地图。文档是写给陌生人的信,而最需要这封信的陌生人,永远是未来的自己。
五、最响的信号是沉默
上线几天后,用户说:搜索挂了。
我按惯性排查:本地服务健康,真实搜索返回十条结果,上游与密钥都好——问题锁定在 Cloudflare 版。于是挂上 wrangler tail,盯着线上日志,访问页面——什么都没有发生。零日志,一条都没有。
那是一个奇特的时刻。传统调试是读错误,可这里没有错误可读:请求没有失败,请求根本没有到达。Worker 看不见它,因为 Static Assets 在 Worker 之前就把请求截住了——SPA 模式下,凡未命中静态文件的路径,Cloudflare 默认直接返回 index.html 兜底,/api/search 也不例外。前端的 JavaScript 加载得好好的,它发出请求,请求落进静态资源层的兜底逻辑,被一张 index.html 礼貌地接住,然后无声消失。用户看到的则是页面正常、点击无应答——一场没有尸体的失踪。
排查是沿着假设树往下走的:本地正常,排除上游与密钥;线上页面能打开,排除 DNS 与证书;日志全空,于是只剩一个解释——请求没有穿过到达 Worker 的那道门。剩下的工作只是逐层验证"它到底在哪一层被拦下",而答案藏在平台的默认行为里。调试的困难从不在技术,而在 willingness:愿意承认"我对路径的理解是错的",愿意把每一层自以为是摊开重数一遍。
修复是一行配置:run_worker_first = ["/api/*"]。找到它却需要先接受一个反直觉的事实:这一次,没有东西出错。
错误是奢侈品,沉默才是常态。系统最常见的死法不是爆炸,而是静默地走错路——请求被别的东西接住,状态被别处覆盖,日志里一片安详。调试的最高技能不是会读 stack trace,而是观察缺席:本该发生的事没有发生,本该出现的日志没有出现。
每一层"贴心"的默认值,也都是一次权力的让渡。SPA 兜底、自动重定向、智能降级——框架替你做的每一个决定,都在你看不见的地方改变了系统的行为。用框架就要知道它在你背后做什么,否则你不是在使用它,而是在被它使用。
六、细节的暴政
主体功能很快就能跑通,剩下的时间全部花在细节上,而细节拒绝被匆匆带过。
手机端的第一次验收是一场屠杀:搜索框溢出屏幕、标签页被截断、按钮挤成两行。我逐个收拾:热词在窄屏只保留三条,标签页收紧字距,操作按钮允许换行,底栏钉死在视口底部并用安全区内边距避开 iPhone 的小白条。每次改完都用 390 像素的窗口重新验收,直到那块虚拟小屏上一切各归其位。
剪贴板是另一场战斗。navigator.clipboard 在非安全上下文里直接拒绝工作,降级方案于是叠了三层:现代 API → execCommand 兜底 → 手动复制的弹窗。复制这个动作小到不值得写进需求文档,但用户一旦复制失败,他对整个产品的信任就塌了一角。
再后来是缩放。用户只说了一句"页面不要缩放",背后却是一整套约束:viewport 锁死 user-scalable=no,CSS 用 touch-action 灭掉双击缩放——然后发现 iOS Safari 出于无障碍政策根本不理会 user-scalable。你无法赢得所有平台,只能在自己的边界内把该做的做完,并对系统级的任性保持谦卑。底栏上的两个外链,从长网址改成纯图标,又改成"文字加图标"——三次往返,每次都是一轮完整的改、部署、刷新、验证。
这些改动没有任何一处值得写进简历,但产品的人格恰恰由它们构成。**用户不会因为你用了什么架构而尊重你,只会因为复制没有失败而信任你。**细节是尊重的兑现形式。
七、诚实的分数
如果给这份代码打分,我给 7 分。
加分项:XSS 转义纪律严格,所有用户输入与 API 内容在插入 DOM 前一律转义;输入校验完整,查询词、URL、count 都有边界;重试、超时、缓存三层韧性,408、429、5xx 各退避三次重试;错误映射人性化——402 会告诉用户"余额不足",而不是甩一个状态码在脸上。
扣分项也清楚:server.js 与 worker.js 逻辑重复;app.js 一千五百余行挤在一个 IIFE 里;没有测试;没有限流。关于测试我承认得格外坦然:个人项目里,测试常常是一种奢侈品——写它的时间够把功能重写两遍,而功能还在狂奔。我选择了用真实请求当冒烟测试,每次部署后亲手点一遍主路径。这是手工作坊的做法,成不了规模,但在当前体量下,诚实比仪式重要。有些债已经还了——项目搬进正式 git 仓库,正本归位,README 写明迁移说明与日常运维命令;有些债还在账上。
我愿意公开这个不满分的分数,是因为越来越怀疑"完美交付"这个叙事。真实的工程是债务管理的艺术:知道哪些债必须今天还,哪些可以背到明天,哪些背一辈子也无所谓。代码的质量不在于它有没有缺点,而在于它的缺点是否诚实。
八、结语:工程即翻译
回头看,这个项目真正做的事情只有一件:翻译。
把 API 的能力翻译成用户的手感,把 Google 的纪律翻译成自己的皮肤,把一套逻辑翻译成两具身体,把一次沉默的故障翻译成一行配置。工程师这个职业的本质,或许就是在"机器能做什么"与"人愿意用什么"之间,做那个不署名的译者。
搜索框还是那个搜索框,敲字,回车,世界展开。但支撑这个古老动作的,是重试与退避,是边缘机房里短暂醒来的一个 isolate,是四级回退的密钥链,是那行差点被永远忽略的 run_worker_first。
能力值得一张脸。而脸,值得所有这些看不见的功夫。