摘要
做一个在浏览器里调模型的 Playground,聊天界面本身没什么难的。真正花时间的是一个安全问题:用户要粘贴 API Key 进来,我怎么保证它不落到任何地方——不进 URL、不进浏览器历史、不进 localStorage、不进 Query cache、不进日志。这篇讲这个设计,以及做的时候踩到的两个 SSE 解析的坑。
技术博客与学习记录
做一个在浏览器里调模型的 Playground,聊天界面本身没什么难的。真正花时间的是一个安全问题:用户要粘贴 API Key 进来,我怎么保证它不落到任何地方——不进 URL、不进浏览器历史、不进 localStorage、不进 Query cache、不进日志。这篇讲这个设计,以及做的时候踩到的两个 SSE 解析的坑。
这次重设计的起点不是"界面不好看",而是一件更实际的事:前端用的 Geist 字体不完整覆盖简体中文,而 logo 的 SVG 里声明了根本没打包的 Inter。字体是可分发资产,牵涉许可证的随附义务,所以我把它当成第一优先级。这篇讲动手前的那份量化基线,以及过程中哪些东西我说服自己放弃了。
有一段时间我的审计日志一条都没记下来,而且无法通过看日志发现——因为日志系统本身没接上。空手而归和从未出发,在观测上是一样的。这篇讲这件事,以及我在指标、审计、追踪这三套东西上的其他取舍。
订阅账号会遇到各种"暂时不能用":被限流、额度用完、凭据过期。这些状态都要有退出机制。自然的做法是定时扫一遍把该恢复的恢复——但 channel-service 有多个副本,于是四个副本会同时扫。这篇讲我怎么把"多副本并发"当成默认前提来设计这两个 sweeper。
第 12 篇讲了三层缓存却留了个尾巴:缓存什么时候该失效。纯 TTL 的答案是"一个被禁用的 token 最多还能用 5 分钟",我不太能接受。这篇讲我最后怎么做的——没有引入消息队列,而是用了一张表加 Redis Streams,以及这条路上踩到的几个坑。
relay 处理一个请求要打四次下游 gRPC,而用户 token、渠道配置这些数据的变更频率是"每分钟几次"、读取频率是"每秒上千次"。这篇讲我怎么搭这个缓存,其中有一个我一开始想错的地方——我一度把"缓存命中"当成了"授权通过"。
我最初以为"额度"只有一个概念:用户买了套餐,每月能用多少,超了就停。写下去才发现上游也在算额度,而且它算的跟我不一样——它报百分比,我记美元;它按自己的 5h/7d 窗口,我按用户的日/周/月。这篇讲我最后怎么同时维护这两套账,以及它们各自最容易出错的地方。
接上游 provider 这件事,我一开始以为是"一家一个适配器"。写到第十家才发现,真正需要单独写协议转换逻辑的只有四家,其余二十多家全是同一个适配器的不同配置。这篇讲这个拆分怎么形成的,以及我在接外部地址时踩到的两条边界——SSRF 防护和流式超时,两条我都做错过。
有一批请求要的模型在所有渠道上都没配置,channel-service 全部返回"没有可用渠道"。这个错误跨 gRPC 回来是 codes.Unknown,而我的熔断器把它算作失败——于是用户传错一个模型名,整个网关的流量都被熔断器拒了。这篇讲这次之后我怎么重排错误分类,以及跨服务降级的其他几处取舍。
并发一上来,上游返回 429,而我当时的逻辑把 429 当失败,重试几次就把一个完全健康的账号标记成不健康、从候选里摘掉。这就是"用满"和"用坏"没分开的后果。这篇讲我最后怎么在限流器、冷却时长、重试策略、健康记录、指标标签每一处把它们分开。