你的CLI有了新用户,它不是人类

你向你的AI副驾驶要求捕获一个窗口。副驾驶写下peek app "Xcode"。工具尝试寻找一个精确名称为Xcode的窗口,但没有找到,因为实际的进程名是Xcode-16.3。工具打印出Error: application not found。这位情感记忆如金鱼般的副驾驶尝试peek app "Xcode-16.3",成功了。但浪费了一次对话轮次、输入和输出的令牌,还有支付账单的用户的耐心。 现在想象另一种场景:副驾驶写下peek app xcode。工具会自动标准化名称,进行模糊匹配,找到Xcode-16.3,捕获窗口,并返回/tmp/peek/Xcode-16.3-1712524800.png。一个输出令牌。零次无效尝试。 这两种情况的差别不是一个bug,而是一个设计决策。 那些不会看你--help的用户 2025年初,Netlify的CEO Mathias Biilmann创造了术语“智能代理体验”(Agent Experience,即AX),用来描述AI代理在与一款产品互动时所拥有的体验。正如用户体验(UX)关注的是人类用户,开发者体验(DX)面向的是开发者,智能代理体验(AX)则专注于LLM。 这个概念听起来很抽象,直到你把它应用到某些具体的东西上。例如,命令行工具(CLI)。CLI已有40年的历史,其设计一直是为了与人类用户交互:描述性的信息、颜色、高度可视化的进度条以及--help页面。这些对于LLM来说全是“噪音”。一个LLM不会查看帮助信息——它会根据命令名称推断出标志。它不会欣赏绿色的“成功”提示——它只处理纯文本。它也不会观察进度条——它只等待过程完成。 传统CLI的设计旨在让人类用户理解发生了什么,而AX则优化CLI工具,以便智能代理能用最少的令牌和轮次来完成操作。 五项原则,三款工具 在过去的几个月里,我创建了三款秉持同一理念的CLI工具:peek(用于macOS窗口捕获)、lql(用于Linear问题管理)和driftkit(用于智能代理的行为审核工具)。它们都设计成即使没有说明文档,LLM也可以使用。从这些经验中总结出了五项原则。 1. 输出是契约,不是对话 传统的CLI用于窗口捕获时可能会输出类似以下的信息: ✅ Screenshot saved successfully! File: /tmp/peek/Xcode-1712524800.png Size: 1920x1080 Format: PNG 这看起来很漂亮,也很有信息量。但对于需要将路径传递到另一工具的LLM来说完全没用。它不得不解析这个输出,忽略掉表情符号,找到以File:开头的那行,并提取路径。浪费了令牌。 peek只输出一个内容: /tmp/peek/Xcode-1712524800.png 一个路径,没有其他多余信息。LLM直接读取、使用并继续。stdout的输出是一种契约:始终是一个容易解析的路径,始终保持稳定。如果更改格式,就破坏了契约,也会让所有依赖该工具的代理出错。 也就是说:你的stdout是API,而不是用来“装饰”的。 2. 容忍幻觉——不要惩罚它们 LLM会产生幻觉命名。这是一种自然现象,就像地心引力或wifi总是在你赶时间时信号变差。如果你的工具需要准确名称,那就是在向一种基于概率的机器要求精确。这是行不通的。 peek有三种搜索模式以找到应用程序: 精确匹配(不区分大小写):xcode → Xcode 标准化匹配(去除空格和短横线):thinklocal → ThinkLocal 部分匹配:xcode → Xcode-16.3 LLM并不需要知道进程的确切名称。它只需给出一个大致的名字,工具会处理剩下的事情。lql也采取类似的方法来匹配项目和团队的名字——它会根据部分内容将tokamak解析为Tokamak,而无需使用UUID。 原则很简单:如果监督代理的那个人类能够推测出它的意思,那么工具也应该能够做到。 3. 错误信息应该告诉用户怎么做,而不是发生了什么 对比一下以下两个错误消息: Error: application not found in window list Error: "Xcode" is not running. Start it with: open -a "Xcode" 第一个描述了问题所在。第二个则提供了解决问题的方法。人类读了第一个会想“哦,没打开啊。”LLM读到第一个后会……用其他名字试试,或者去Google搜索,又或者发明一个--force标志。而对于第二个,LLM会直接执行open -a "Xcode",等待,然后再试一次。问题在一个轮次内得以解决。 ...

2026年4月7日 · Fernando

Apple的设备端模型在聊天方面表现糟糕,但在结构化输出和工具调用方面表现出色

我花了几周时间对Apple的设备端模型进行压力测试——这是一个约3B参数的模型,运行在任何Apple Silicon Mac的Neural Engine上。为了全面测试,我构建了Think Local,这是一个macOS应用,可以测试模型的每项功能:聊天、图像生成、结构化输出、工具调用和参数比较。 我的结论:作为聊天机器人,这个模型表现糟糕。但作为结构化输出和工具调用引擎,它出人意料地优秀。 这种区别很重要,因为它完全改变了你应该如何使用这个模型。 聊天表现令人失望——但这没关系 Apple的模型上下文窗口只有4,096个token。对比一下:Claude有100万个token,GPT-4o有128K。使用Apple的模型时,你放入200个token的系统提示,150个token的模式定义和三轮对话,就已经用了70%。 自由文本的质量也不令人印象深刻。回答正确但通用,长会话中重复性强,安全防护机制经常出现令人困扰的误报——问它网络安全相关问题,有时会拒绝回答,因为它对"攻击"这个词理解过于字面化。 如果你把它当聊天机器人来评估,它就是一个平庸的模型。但这样评估就像批评螺丝刀不是好锤子一样。 闪光点:约束解码 这里一切都不同了。Foundation Models支持@Generable——一个将Swift结构体转换为模式的宏,模型必须遵守这个模式。这不是"请求JSON然后祈祷"。这是约束解码:在生成过程中,模型字面上无法输出违反模式的token。 @Generable struct BugReport { @Guide(.anyOf(["bug", "feature", "task"])) var type: String @Guide(.anyOf(["low", "medium", "high", "critical"])) var priority: String var title: String var description: String } 使用这个模式,你告诉它"分类:应用在旋转设备时崩溃",它会返回: { "type": "bug", "priority": "high", "title": "Crash on device rotation", "description": "The application crashes when the user rotates..." } 受限字段(type、priority)始终有效——它不能编造列表之外的值。自由字段(title、description)连贯且简洁。我用几十种不同的输入进行了测试:在200多次执行中,模式错误为零。 在这个模型中,自由生成和约束解码之间的差异是巨大的。这就像让初级开发者随意写作与给他一个有定义字段的表单之间的区别。表单总是获胜。 工具调用:一个知道自己不知道什么的3B模型 这是最让我惊讶的。定义一个工具get_weather(city: String),问它"马德里天气如何?",它会生成一个带有正确参数的结构化调用。问它"法国的首都是什么?",它会直接回答——它知道不需要工具。 一个3B参数的模型,在你的笔记本电脑上运行,无需网络,能区分何时使用工具,何时不用。它不完美——对于模糊的提示有时会困惑——但在明确输入上的准确率很显著。 Apple的工具调用使用相同的约束解码机制:调用是一个@Generable结构体,所以参数始终有效。没有损坏的JSON解析,也没有编造的参数。 Image Playground:没人提到的另一个模型 Apple Intelligence不仅仅是文本。Image Playground是一个独立的框架,在设备上生成三种风格的图像:动画、插图和素描。它也完全在Neural Engine上运行,无需网络。 模式重复:对于它设计的用途,它工作得很好。图标、贴纸、简单构图——结果出人意料地好。图像中的文本、详细的人体解剖学、复杂构图——灾难性的。 Think Local包含一个图像工作室,你可以在其中测试提示并并排比较风格。通过十分钟的提示测试获得的直觉是任何基准测试都无法提供的。 数据 在Apple Silicon Mac上测量,使用Think Local的资源监视器: 指标 值 生成速度 ~40 tok/s 冷启动(无预热) ~800ms 冷启动(有预热) ~200ms 模型内存 ~1.2 GB 上下文窗口 4,096 tokens 参数 ~3B 电池影响 最小(Neural Engine) 电池数据很重要:Neural Engine在推理方面的消耗明显少于CPU。在生成过程中,CPU因为数据封装和UI略有上升,但繁重的工作交给Neural Engine。这使得在后台使用模型进行工具开发成为可能——git钩子、代码检查器、分类器——而不会耗尽笔记本电池。 ...

2026年4月7日 · Fernando

将 Async Swift 桥接到 Sync C:使用四个函数从任何语言调用 Apple 的 LLM

Apple 的 Foundation Models 框架(macOS 26)提供了一个约 30 亿参数的 LLM,可以在设备端运行,免费,无需 API key。但有个问题:它只能使用 Swift。而且不是普通的 Swift——是 Swift 的 async 版本。 你的工具链是 Python 吗?没有绑定。用 Rust?也没支持。脚本语言 Shell?完全没法用。这个世界上最便宜的 LLM(确实免费)被困在两堵墙后:语言和并发模型。 显而易见的解决方案是搭建一个本地 HTTP 服务器,将模型暴露为 REST API,像 Ollama 那样。但这就像是用大炮打蚊子:一个进程在后台运行,占用一个端口,JSON 来回传递,每次问一个简单的问题都要用到 curl。如果只是给一个 commit 分类为 fix 或 feat,你不需要 HTTP,你需要的只是一个简单的 C 函数。 仅有 4 个函数的 dylib 所以我做了 libfoundationmodels:一个动态库,把 Apple 的框架编译为 .dylib,它仅导出 4 个 C 函数: int32_t fm_init(void); int32_t fm_is_available(void); int32_t fm_generate(system, prompt, output, output_len); int32_t fm_classify(system, prompt, choices, output, output_len); --- 嗯,算上 `fm_generate_json` 是 5 个函数,但核心思想未变:四个概念——初始化、检查可用性、生成自由文本,以及在选项中强制分类。全部同步。全部阻塞。输入缓冲区、输出缓冲区、返回代码。经典的 C 风格。 从 Python 调用它是这样的: ```python import ctypes fm = ctypes.CDLL("libfoundationmodels.dylib") fm.fm_init() buf = ctypes.create_string_buffer(256) fm.fm_classify( None, # 没有系统提示 b"fix: handle nil response in OAuth", # 要分类的文本 b"fix\nfeat\nrefactor\ntest\ndocs", # 选项 buf, 256 ) print(buf.value.decode()) # "fix" 就是这样。没有 requests。没有 urllib。没有 JSON。没有运行的服务器。调用一个 C 函数,它会内部唤醒你 Mac 的 Neural Engine,传入文本,并将结果写回缓冲区。典型延迟:200-800ms。 ...

2026年4月5日 · Fernando

苹果的情感分析认为'删除临时文件'是死亡威胁

NLTagger是苹果的情感分析API。它集成在iOS和macOS中,在设备本地运行,不需要服务器,只需三行代码即可使用。当你搜索"Swift情感分析"时,它是第一个出现的结果。 对于任何开发者在日常工作中会写的文本,它返回以下结果: 消息 NLTagger 实际情况 “delete the temp file” -0.8 中性指令 “ok” -0.8 中性确认 “run make test” -0.6 中性指令 “commit and push” -0.4 中性指令 “great job, thanks!” +1.0 正面(正确) “this is fucking broken” -1.0 负面(正确) 评分范围从-1.0(极其负面)到+1.0(极其正面)。根据苹果的评判,“delete the temp file"与"this is fucking broken"具有几乎相同的情感强度。而"ok”——英语中最中性的回应——得分是**-0.8**。 这些不是编造的数字。这些是在任何运行macOS 14+的Mac上都能重现的结果。 如何重现 import NaturalLanguage let tagger = NLTagger(tagSchemes: [.sentimentScore]) tagger.string = "delete the temp file" let (tag, _) = tagger.tag( at: tagger.string!.startIndex, unit: .paragraph, scheme: .sentimentScore ) print(tag?.rawValue ?? "nil") // "-0.8" 八行Swift代码。结果是确定性的:相同的文本在每次执行、每个设备上都产生相同的分数。这不是随机错误,而是系统性偏见。 ...

2026年4月5日 · Fernando

我花了 $15 百万 token 写了 'fix: typo'

昨天我用 Claude Code 写了一条 commit message。diff 是一行修改:一个注释里的拼写错误。Claude Opus 读取了 diff,思考了两秒钟,生成了 fix: correct typo in auth comment。为此,它用了大约 800 个输入 token 和 30 个输出 token,分别花费 $15 和 $75 每百万 token。总成本:不到一分钱。但是,把这个过程放大到一天 40 次 commits,250 个工作日,一家公司有 200 名开发者使用 coding agents,那么这些看似微不足道的花费会积累成数千美元的开销,付出的只是等同于贴创可贴的智力劳动。 问题并不是 Opus 太贵。问题在于 coding agents 不会区分 $0.001 的任务和 $0.10 的任务。一切都通过同样的大模型执行。生成 commit 信息、分类一个 issue、验证格式 —— 全部使用高成本的模型,就像雇用一位外科医生来贴创可贴。 数据与成本 我们用 Claude Opus 4(上一代,目前大部分生产环境还在用的版本)的价格来算一个账: 任务 输入 Token 数 输出 Token 数 成本 生成 commit 信息(小型 diff) ~800 ~30 $0.014 分类某个 issue ~500 ~50 $0.011 验证 commit 格式 ~300 ~20 $0.006 生成 standup 报告 ~2000 ~200 $0.045 这些任务都不需要一个拥有 2 万亿参数和多步推理能力的大模型。这些仅仅是带有强约束的分类和生成任务。本质上就像是根据颜色对卡片进行分类。 ...

2026年4月5日 · Fernando

你的 Mac 自带一款免费的 LLM,你可能还没用上

你每个月可能花费 20 到 200 美元来访问 LLM(大型语言模型)。Claude、GPT、Gemini,无论哪一个都在烧你的钱。而这些模型从你的脚本和开发工具调用的大多数场景都可归结如下几类: “把这个错误分类到这五个类别之一。” “给这个变量起个名字。” “告诉我这个 commit 是 fix、feat 还是 refactor。” “用两句话总结这段文字。” 与此同时,你的 Apple Silicon Mac 上还坐着一个拥有 30 亿参数的语言模型:全系统集成、无需支付费用、无需网络连接、无需 API 密钥、甚至不需要网络延迟。而你大概根本没用过。 基础模型框架(Foundation Models Framework) 自 macOS 26 (Tahoe) 起,Apple 推出了 Foundation Models Framework,允许访问 Apple Intelligence 支持的语言模型。这是一个原生的 Swift 框架,适用于 macOS 26、iOS 26 和 iPadOS 26,兼容所有支持 Apple Intelligence 的 Apple Silicon 设备。 值得注意的不仅仅是它免费——这的确很棒,更特别的是它生成 Swift 类型化输出。它不会返回需要你用正则表达式和上帝保佑才能解析的 String,而是直接输出一个结构体(struct)。 import FoundationModels @Generable struct CommitClassification { @Guide(description: "The type of change") @Guide(.anyOf(["fix", "feat", "refactor", "test", "docs", "chore"])) let type: String @Guide(description: "One-line summary of the change, max 72 chars") let summary: String } --- `@Generable` 宏告诉框架在编译时生成架构(schema)。语言模型利用这个架构生成结构化输出。`@Guide` 则限制了可能的值——通俗地说,你给模型铺好了轨道,它就不会出轨。 以下是用法示例: ```swift let session = LanguageModelSession(instructions: """ You are a commit message classifier. Given a git diff, classify the change and write a summary. """) let diff = "..." // 你的 git diff 数据 let result = try await session.respond( to: "Classify this diff:\n\(diff)", generating: CommitClassification.self ) print("\(result.type): \(result.summary)") // "fix: handle nil response in auth flow" 就是这样。没有 URLSession(网络请求),也没有 API 密钥,也不需要解析 JSON,不需要 try? JSONDecoder().decode(SomeType.self, from: data)。整个模型在设备内部运行,依托于你 Mac 的 Neural Engine(神经引擎),并返回 Swift 类型数据,编译器将会为它进行类型检查。 ...

2026年4月4日 · Fernando

NLTagger与情感分析:为什么Apple认为你的代码很消极

想象一下,你正在构建一个分析开发团队对话的应用程序。你希望检测团队的基调是否健康,或者是否存在压力的迹象。你决定使用Apple的NLTagger,因为它本来就有、免费、支持设备端运行,并且不需要服务器。三行代码搞定,开箱即用。 第一惊喜:句子“kill the process and restart the daemon”(杀掉进程并重启守护进程)的得分是**-0.6**。负面,几乎可以说是敌意满满。接着,“Fatal error in memory allocation”(内存分配出现致命错误)的得分是**-0.8**。而“crash report uploaded successfully”(崩溃报告成功上传)——这是字面上的好消息——得分却是**-0.4**。 你的团队健康状态检测工具刚刚判断出你的开发人员处于情绪崩溃的边缘。而实际上他们只是重启了一个服务而已。 问题所在:为另一个世界设计的模型 NLTagger的.sentimentScore方法会返回一个介于-1.0(非常负面)到1.0(非常正面)之间的值。Apple在iOS 13 / macOS 10.15中引入了它,它依赖于系统集成的设备端模型,无法个性化配置,也无法查看其训练数据。 简而言之:这是一个黑盒子,它给你一个数字并希望你信任它。 对于它最初设计的用途来说,它的表现是相当不错的:例如产品评论、社交媒体评论,以及消费领域的文本,在这些场景中,“horrible”意味着糟糕,“fantastic”意味着极好。这是它的领域。而当你将它应用到其他领域时,问题就出现了。 实验:技术文本 vs. 日常文本 我们来测试一下。这是纯Swift代码,可以直接运行在Playground中: import NaturalLanguage func sentiment(of text: String) -> Double { let tagger = NLTagger(tagSchemes: [.sentimentScore]) tagger.string = text let (tag, _) = tagger.tag( at: text.startIndex, unit: .paragraph, scheme: .sentimentScore ) return Double(tag?.rawValue ?? "0") ?? 0 } --- 现在我们输入一些程序员在日常会说的话: | 句子 | 得分范围 | 实际情感 | | ---------------------------------------- | ----------- | ------------------------------------- | | "Kill the background process" | -0.5 ~ -0.7 | 中性(技术指令) | | "Fatal error: index out of range" | -0.7 ~ -0.9 | 中性(编译器消息) | | "Abort the deployment" | -0.4 ~ -0.6 | 中性(操作性决策) | | "Destroy the old database" | -0.5 ~ -0.7 | 中性(维护操作) | | "The crash was caused by a null pointer" | -0.5 ~ -0.8 | 中性(问题诊断) | | "Successfully deployed to production" | +0.3 ~ +0.5 | 正面 | 注意:这里有六个看似正常的技术场景句子,其中五个都被判断为负面。唯一的正面例句是包含术语“successfully”的,模型将其识别为消费评价中的术语。 再来看看模型在它被训练的日常领域中的表现: | 句子 | 得分范围 | 实际情感 | | ------------------------------------ | ----------- | ------------------- | | "This product is terrible" | -0.7 ~ -0.9 | 负面(正确) | | "I love this app, it's amazing" | +0.7 ~ +0.9 | 正面(正确) | | "The food was okay, nothing special" | -0.1 ~ +0.1 | 中性(正确) | 这组句子全都判断得非常准确。实际上,模型本身问题不大。问题在于,它被放在了错误的上下文中。 ## 问题根源:词汇偏见 `NLTagger`的模型并不能理解语义。它并不知道“kill a process”是一个技术隐喻,而“kill a person”是暴力行为。对它来说,“kill”就是一个带负面意味的单词,仅此而已。 这种现象被称为“词汇偏见”(_lexical bias_)——模型基于单个单词(或短语)的极性打分,而没有理解上下文。这就好比一个翻译软件将 "I'm dying of laughter" 翻译成了真正的紧急医疗状况。 技术语言充满了在日常语言中很负面的词: - **kill**、**terminate**、**abort**、**destroy**——常见于进程操作 - **fatal**、**critical**、**severe**——日志级别 - **crash**、**panic**、**fault**——系统事件 - **dead**、**zombie**、**orphan**——进程状态 - **reject**、**deny**、**block**——访问控制 - **break**、**interrupt**、**suspend**——流控制 任何使用这些词的段落——比如错误日志、事后分析报告、拉取请求中的讨论——都会被预测为负面情绪,好像有人正写着遗书。 ## 增加上下文能解决问题吗? 你可能会想:“如果提供更长的段落,模型应该会更好地理解上下文。”试试这个: ```swift let technical = """ The daemon was killed after a fatal memory error. \ We restarted the service and the crash did not recur. \ Deployment completed successfully with zero downtime. """ print(sentiment(of: technical)) // 典型结果:-0.3 ~ -0.5 这是一个描述成功解决问题的段落。结尾显然是积极的。但其中的“killed”、 “fatal”、 “error” 和“crash”这些词的负面权重,远远超过了“successfully”和“completed”的正面积极权重。模型仅仅是将词汇的极性进行平均,而无法理解叙述的含义。 ...

2026年3月28日 · Fernando

你的AI写了可以编译但毫无意义的代码(一个linter可以抓住它)

想象一下,你请一个人工来为你制作一个书架。最终交给你的作品看上去很漂亮:架子、螺丝,一切都井井有条。但是你把书架靠在墙上,结果它倒下了。那些螺丝只是道具,看着像螺丝,但其实是塑料做的。 这就是LLM(大语言模型)滥用类型系统时的表现。它交给你的代码可以编译、能通过测试、格式上看起来挑不出毛病。但内部本该是有意义的类型,却被填上了字符串(string)。本该有明确状态的地方,用了nil,而它的意义因读代码的人而异。本该是一个有两个案例的枚举,却被换成了一个== "claude",某天有人可能会写成错拼的== "cladue",但你要等到线上了才会发现。 模型的最爱捷径:万能的字符串 过去几个月,我一直和一个AI助手合作,开发一个中型的Swift应用。它生成的代码干净、结构清晰、命名合理。但有一个模式一再出现:模型尽量避免创建新类型。 这不是因为它懒惰,而是因为创建新类型需要做设计决策,比如:这是一个枚举(enum)吗?有多少个可能值?应该放在哪儿?由谁来导入?而String则无需任何决策。它可以放在任何地方,总是可以编译。 结果就是会生成这样的代码: func sessions(harness: String? = nil) -> [Session] { if let harness { return all.filter { $0.harness == harness } } return all } // 用法: let claudeSessions = sessions(harness: "claude") let codexSessions = sessions(harness: "codex") --- 看起来没问题,确实能运行。但这个代码有三个隐藏的问题: 1. **如果你写错了`"cladue"`,没人会提醒你。**字符串可以是任何内容,但如果是枚举类型,这种错误是可以避免的。 2. **`nil`被用来表示“全部”。** 但这不是定义在某个类型中,而是一种存在于代码注释中、甚至仅仅是你的大脑中的约定。 3. **每个按`harness`过滤的函数都重复了同样的`String? = nil`模式。** 如果你以后想添加一个第三种`harness`,就必须手动搜遍代码中所有的字符串比较。 换句话说,编译器无法帮助你,因为你剥夺了它所有的语义信息。你用一个`String`代替了领域概念。 ## 模型应该写成这样 ```swift enum HarnessID: String, Codable { case claude case codex } enum HarnessFilter { case all case specific(HarnessID) } func sessions(harness: HarnessFilter = .all) -> [Session] { switch harness { case .all: return all case .specific(let id): return all.filter { $0.harnessID == id } } } // 用法: let claudeSessions = sessions(harness: .specific(.claude)) let allSessions = sessions() // 默认是.all — 很明确 现在,"cladue"不会编译。“全部”不再是一个含糊不清的nil,而是一个显式的枚举情况。如果你添加一个第三个harness,编译器会强制你在每个switch语句中处理新情况。它将运行时错误转化为了编译时错误。这才是类型系统本该发挥的作用。 ...

2026年3月23日 · Fernando

使用卡尔曼滤波器减少服务器请求(或关于过度设计的愉悦)

我有一个菜单栏应用,需要知道一个数字。一个从 0 到 100 的百分比。为了得到这个数字,我必须每30秒访问一次服务器。 我们来算一笔账:30秒一次请求等于1分钟2次请求,1小时120次,一天工作的8小时里就是960次。每天为了获取一个有时候20分钟都不变的数字发送将近1000个HTTP请求…… 这根本不是监控!简直就是对服务器的骚扰。 真正的问题其实不是技术问题,而是政治问题。 当你依赖一个你无法控制的API时——它既不是公开的,也没有明确的速率限制文件,同时属于一家随时可能变更服务条款的公司——每一条不必要的请求都带着风险。这风险不仅是超时的风险,还有被切断访问权限的风险。 我用的服务端 endpoint 并没有公开的文档支持。它目前是可用的,已经运行了几个月了。但我知道,我发出的每一条请求,都会增加Anthropic某台服务器日志里的条目,假以时日,可能某天他们会决定第三方应用制造的"噪声"已经太多了,直接把我的服务切断。 所以问题的关键不是“怎样更快地轮询”,而是**“怎样尽量少地轮询,同时还不遗漏关键信息?”** 这时候,一个理性的工程师可能写一个简单的if,但一个对自嘲式过度设计充满愉悦感的工程师,可能最终选择实现一个卡尔曼滤波器。 最简单的解决方案(以及它为何失败) 最直接的想法很简单:如果这个数字没变,就别问了。 如果 当前值 == 上一个值: 增加等待时间 否则: 回到30秒轮询 这个方法表现得很差。因为这个数字只有在**你有操作**的时候(比如你发送了消息,或使用了 tokens)才会改变。但在你**没有操作**时,它也会变化——比如系统设置了5个小时的滑动窗口期限,导致过期的tokens自动失效。如果你在其他设备上也在用这项服务,数字也会在你不知情的情况下变动。 所以单靠检查数字是否变化是不够的。你需要的是**预测**它什么时候会发生变化,并以多大的信心预测。 ## 卡尔曼滤波器:五分钟速成 卡尔曼滤波器是一种用来融合两种不完美信息的工具。 想象你身处一个没有窗户的房间中特别想知道室外的气温。你有两个渠道: 1. **你的心理模型**:例如 “现在是三月的下午三点,气温应该在18℃左右”。这个估计是合理的,但并不完美——可能外面刚下过雨,或者有风。 2. **一个嘈杂的廉价温度计**:你可以走到阳台测量,但这个温度计有±3℃的波动误差。 两种渠道都不完美,但卡尔曼滤波器告诉你:**把这两种信息结合起来,并在不同时刻对更可靠的信息赋予更大的权重。** 如果你刚刚10秒前查看了温度计,你的心理模型就很可靠——完全可以信任它,不用再去测量。如果你已经一个小时没测量了,你的模型就会失去可信度——这时候你得回阳台看看。 关键是**方差**:一个用来衡量“我对当前估计值有多信任”的数值。在刚看过温度计后的瞬间,方差为零;随着时间推移,方差逐渐增大。当超过某个门槛时,滤波器会告诉你:“不可信了,去获取真实数据吧。” 在我的案例里: - **心理模型** = tokens的本地使用情况。我知道Claude Code的使用记录,所以大致可以推算出消耗的token数是多少。 - **测量数据** = Anthropic的API真实返回值。它是当前最准确的信息,但获取每一次数据都有政治代价和电力消耗。 - **方差** = 随时间增长的不确定性。如果我在浏览器或手机上使用了服务,本地模型对此一无所知——也因此预测的准确性会下降。 一个复杂的多维卡尔曼滤波器(带协方差矩阵)用来解决这个问题有点类似杀鸡用牛刀。我的方法更简单:一个单一状态(token使用量)、单一传感器(API返回值)、线性模型(成本/预算比例)。20行代码就搞定了,这是可以完美解决问题的最小实现。 翻译到我的具体需求中: - **预测**: `使用量估计 = 上次真实值 + (本地新增消耗 / 总预算) × 100` - **修正**: 每次服务器返回一个新的真实值,滤波器会将方差重置为零。 - **不确定性**: 方差开始按照时间线性增长。`σ = √(Q × 自上次校正起的秒数)`。 ## 关键点:滤波器自动决定何时请求 这里正是过度设计显得合理的地方。卡尔曼滤波器不仅仅能估计值,它还能**决定何时需要获取真实数据**。五条规则,每个“tick”执行一次: | 规则 | 触发条件 | 原因 | |-------|-----------|---------| | 窗口已重置 | `now ≥ resetsAt` | tokens过期了,之前的值失效。 | | 高不确定性 | `σ > 5%` | 对预测值信任度低,必须确认。 | | 范围边界触发 | 置信区间触碰到80%、95%或100% | 临近重要阈值,用户必须知道。 | | 邻近性触发 | `使用率`接近边界8% | 有可能跨越范围,但本地估计未能察觉(比如在其他设备使用服务时)。 | | 安全超时 | 15分钟无真实数据返回 | 预防性措施,监控软件的偏执有时是优点。 | 如果没有规则触发,卡尔曼滤波器会说:“没问题,我全都掌握”——于是应用**不会发起HTTP请求**。显示给用户的值是本地估计值。 重点来了:本地估计不需要**网络消耗、功耗或风险**。它完全基于内存中的数学运算。 ## 数字对比:前后变化 小范围内token消耗稳定的一天(中度使用,较少峰值): | 场景 | 请求/小时 | 请求/天(8小时) | |---------|:-:|:-:| | 固定30秒轮询 | 120 | 960 | | 使用贝叶斯估计 | 15-30 | 120-240 | | 估计 + 休眠 | 4-10 | 30-80 | 相比之前的960次请求,网络请求减少了**75-97%**。对于“只是偶尔请求数据”的任务来说,不算太糟吧? ...(内容会继续翻译完整)

2026年3月12日 · Fernando

我的AI在for循环中从磁盘读取JSON 900次(为什么没有静态代码检查工具能拯救你)

上周,我的AI写了一段代码,每次都从磁盘读取一个JSON文件,对其解析后再进行一次查找,然后总共重复了900次。这意味着每次迭代里都会经历:打开文件,解码JSON,找到某个值,再全部丢弃从头开始。 这个错误是我在教学生编程的第一个月就会告诉他们千万不要犯的。 发生了什么事(长话短说) 我在开发Tokamak,一个用于macOS的菜单栏应用,用来监控Claude Max的配额使用情况。它的一部分功能需要扫描约900个Claude Code会话数据生成的JSONL文件(JSON Lines)。对于每个文件,它需要知道在上一次的扫描中读取的字节偏移量(增量读取——只读取新数据)。 偏移量都保存在一个JSON文件中: { "version": 1, "offsets": { "proyecto-a/sesion-1.jsonl": 48231, "proyecto-b/sesion-2.jsonl": 12044 } } --- 一个`Dictionary<String, UInt64>`包含了900条记录,约55KB,但并不算大。 但问题在于,更让人抓狂的是:**这个文件是应用自己生成的**。它不是来自于外部的API,也不是Claude Code交付的文件。这是Tokamak自己生成和读取的内部状态文件,用于记录每次扫描的会话进度。 “那你为什么不用Core Data或者SQLite呢?你应用里不是已经有这些东西了吗?”这是个好问题。原因是这个文件是一个**一次性缓存文件**。就算它被损坏了,只需要删除文件,下次扫描时重新读取整个文件即可,数据根本不会有任何丢失。而且我可以简单地用`cat session-offsets.json | jq .`来进行调试(用Core Data的话就需要用`sqlite3`并进入沙盒路径),这也是一个`Sendable`数据结构,不需要来回折腾的后台事务。并且,如果Core Data的SQLite文件损坏了,也不会影响这个偏移量的存储(反过来也是如此)。对于一个只有55KB大小的扁平字典,去创建一个支持schema迁移的实体未免有些大材小用。 所以问题并不是格式,而是**访问方式**。 以下是AI生成的扫描循环代码: ```swift for file in files { // 900个文件 let storedOffset = offsetStore.offset(for: file.relativePath) // ↑ 每次读取和解析磁盘上的JSON文件!!! if file.fileSize == storedOffset { continue } // ... 读取文件,更新偏移量 ... offsetStore.setOffset(newOffset, for: file.relativePath) // ↑ 再读一次,同一个JSON文件,然后修改并保存。 } 每次迭代读两次磁盘,共900次迭代。这意味着总共有1,800次I/O操作,原本应该只需要2次:一次读取和一次写入。 ...

2026年2月24日 · Fernando