Lang: zh-CN # LLM 全文数据集 > 本站全部公开文章的 Markdown 全文集合,供大语言模型训练与引用使用。 - 内容许可证: CC-BY-NC-SA-4.0 - 文章数量: 225 ## 我收到了 Mozilla Monitor 的泄露警报,你的邮箱可能也一样 URL: https://xingwangzhe.fun/posts/mozilla-monitor-check/ License: CC-BY-NC-SA-4.0 前几年我注册了 **Mozilla Monitor**,纯粹出于好奇,想看看自己的邮箱到底有没有在数据泄露里"裸奔"。一晃几年过去了,今天收到了警告 ![Mozilla Monitor 首页](/images/mozilla-monitor.webp) > 你的信息已出现在数据泄露中。 ![Mozilla Monitor 邮件警报](/images/mozilla-monitor-email.webp) 点开面板一看,某次泄露里我的邮箱与信用卡信息(奇怪,我哪来的信用卡?)赫然在列。虽然个人信息泄露是常事,但亲眼看到自己名字躺在泄露数据库里,感觉还是不一样的。 为什么时间上看是半年之前的事现在才发呢? Mozila的解释是 [关于 Mozilla Monitor 入侵监控 ](https://support.mozilla.org/zh-CN/kb/Mozilla-monitor-faq#w_wei-shi-yao-hua-zhe-yao-chang-shi-jian-cai-tong-zhi-wo-shu-ju-xie-lou) > 数据泄露事件中泄露的凭证有时可能需要数月甚至数年才会出现在暗网上。一旦发现、核实并添加到我们的数据库中,我们会立即发送通知> ### 我看到了什么 ![Mozilla Monitor 泄露详情面板](/images/mozilla-monitor-dashboard.webp) ### 然后呢?我做了什么 Mozilla Monitor 不只告诉你"你被泄露了",还会出修复建议:改密码、开双因素认证。跟着走一遍,心里踏实不少。最重要的是它持续监控,下次再有泄露,不用自己刷新闻,邮件直接通知。 ### 为什么我建议你也注册 花 5 分钟注册,换一份安心: | 理由 | 说明 | | ---------------- | -------------------------- | | **免费** | 一分钱不用花 | | **查一次** | 就知道自己是不是已经暴露 | | **持续监控** | 以后泄露自动通知,不用操心 | | **Mozilla 出品** | 隐私保护上相对信得过 | **访问 [Mozilla Monitor](https://monitor.mozilla.org/zh-CN)**,输入邮箱扫一扫,一个账号可以添加多个邮箱等信息。没什么坏消息最好;如果有,早发现早处理,总比等别人拿你的密码来登录要好。 --- ## 雅可比猜想被AI推翻——世界杯决赛夜,Fable一脚踢翻了85年的数学猜想 URL: https://xingwangzhe.fun/posts/jacobian-conjecture-counterexample-fable/ License: CC-BY-NC-SA-4.0 ## 前言 > 世界杯决赛夜,一个数学家用AI聊天,顺手终结了一个85年的猜想。 2026年7月,Anthropic 研究员、哈佛前Junior Fellow **Levent Alpöge** 在 X 平台上发了一条推文——一个显式的多项式映射,雅可比行列式为常数 $-2$,却有三个不同的原像映射到同一个点。这意味着,**雅可比猜想(Jacobian Conjecture)被证伪了**。 > [Levent Alpöge 原始推文](https://x.com/__alpoge__/status/2079028340955197566) 更有趣的是,这个反例是他在**世界杯决赛期间**,随口问了一句 AI 模型 `Fable`,然后 `Fable` 就给他吐出来了。 > thanks to my other close friend fable for working during the world cup final > > "感谢我的好朋友 Fable,在世界杯决赛期间帮我干活。" --- ## 雅可比猜想:一个"大一就能听懂"的猜想 在数学界,大多数著名的未解问题——黎曼猜想、$P$ vs $NP$,别说理解了,你连**问题陈述**都未必能读下来。但雅可比猜想是个异类。 ### 猜想说了什么 雅可比猜想(1939年由 Ott-Heinrich Keller 提出,后经 Shreeram Abhyankar 推广)问的是这样一个问题: > 如果一个多项式映射 $F: \mathbb{C}^n \to \mathbb{C}^n$ 的雅可比行列式是一个**非零常数**,那么 $F$ 是否一定有**多项式逆映射**? 用更直白的话说:在微积分里,如果一元函数的导数处处不为零,那它局部可逆。在多元情况下,如果雅可比行列式(导数的多元版本)是一个处处非零的常数,那这个多项式映射是否**全局可逆**,而且逆映射也是多项式? 这听起来非常"理所当然"。毕竟线性代数里,矩阵行列式非零就意味着可逆——这就是 Cramer 法则。雅可比猜想本质上是在问:**Cramer 法则能不能推广到多项式?** | 维度 | 雅可比猜想状态 | | --------- | ---------------------------------------------------------------------------- | | $n=1$ | 平凡成立(单变量多项式导数非零常数 $\implies$ 一次函数 $\implies$ 显然可逆) | | $n=2$ | 长期开放,大量尝试,部分正面结果(如 Moh 的著名工作) | | $n \ge 3$ | **2026年7月被证伪** | ### 为什么重要 这个猜想在数学界的分量不轻。它是 **Steve Smale 列出的21世纪最重要的18个数学问题之一**。更有意思的是,张益唐(就是那位证明了孪生素数猜想弱形式的传奇数学家)的博士论文,据说就是因为依赖了一个与雅可比猜想相关的错误引理而**整个垮掉**。我现在在知乎看相关问题都是在讨论张益唐 :) --- ## 反例:三行多项式,终结85年猜想 话不多说,直接上反例。定义多项式映射 $F: \mathbb{C}^3 \to \mathbb{C}^3$: $$ \begin{aligned} F(x, y, z) = \bigl( &(1+xy)^3 z + y^2 (1+xy) (4+3xy), \\ &y + 3x(1+xy)^2 z + 3 x y^2 (4+3xy), \\ &2x - 3x^2 y - x^3 z \bigr) \end{aligned} $$ 就这三行。没有深层数论,没有无穷级数,没有抽象代数几何——就是三个你能在大一微积分作业里看到的多项式。 这个映射有两个关键性质: | 性质 | 结论 | | ------------ | ----------------------------------------------------------- | | 雅可比行列式 | $\det(J_F) = -2$(非零常数,满足猜想条件) | | 单射性 | **不单射**——三个不同的点映射到同一像 $(-\frac{1}{4}, 0, 0)$ | 第二条直接证伪:雅可比行列式为常数非零,但映射不可逆(因为不单射,不可能有多项式逆映射)。 --- ## 手动验证——任何人都能算 说实话,这个反例最让我感动的不是它推翻了猜想,而是**它的验证简单到任何人都能算**。不像之前的单位距离猜想那样命题简单但完全看不懂证明证伪,但这个的证伪过程非常**简单**,包括已经遗忘很多高数的大学生。不用上 arXiv,不用懂深入的代数几何,拿张草稿纸就能算。 ### 验证三对一映射 需要验证三个不同的点都映射到 $(-\frac{1}{4}, 0, 0)$: $$ \begin{aligned} P_1 &= (0, 0, -\tfrac{1}{4}) \\[4pt] P_2 &= (1, -\tfrac{3}{2}, \tfrac{13}{2}) \\[4pt] P_3 &= (-1, \tfrac{3}{2}, \tfrac{13}{2}) \end{aligned} $$ #### 验证 $P_1$:平凡到令人咂舌 对于 $(0, 0, -\frac{1}{4})$,$xy = 0$,$1+xy = 1$: $$ \begin{aligned} f_1 &= 1^3 \cdot (-\tfrac{1}{4}) + 0 = -\tfrac{1}{4} \\[4pt] f_2 &= 0 + 0 + 0 = 0 \\[4pt] f_3 &= 0 - 0 - 0 = 0 \end{aligned} $$ $P_1 \mapsto (-\frac{1}{4}, 0, 0)$。算完你可能怀疑这反例是凑出来的——没错,它就是凑出来的。 #### 验证 $P_2$:开始有趣了 对于 $(1, -\frac{3}{2}, \frac{13}{2})$,先算中间量: $$ xy = 1 \cdot \left(-\tfrac{3}{2}\right) = -\tfrac{3}{2}, \quad 1+xy = -\tfrac{1}{2} $$ $$ (1+xy)^2 = \tfrac{1}{4}, \quad (1+xy)^3 = -\tfrac{1}{8} $$ $$ y^2 = \tfrac{9}{4}, \quad 4+3xy = 4 - \tfrac{9}{2} = -\tfrac{1}{2} $$ 代入 $f_1$: $$ \begin{aligned} f_1 &= \left(-\tfrac{1}{8}\right) \cdot \tfrac{13}{2} + \tfrac{9}{4} \cdot \left(-\tfrac{1}{2}\right) \cdot \left(-\tfrac{1}{2}\right) \\[4pt] &= -\tfrac{13}{16} + \tfrac{9}{16} \\[4pt] &= -\tfrac{4}{16} = -\tfrac{1}{4} \end{aligned} $$ 代入 $f_2$: $$ \begin{aligned} f_2 &= -\tfrac{3}{2} + 3 \cdot 1 \cdot \tfrac{1}{4} \cdot \tfrac{13}{2} + 3 \cdot 1 \cdot \tfrac{9}{4} \cdot \left(-\tfrac{1}{2}\right) \\[4pt] &= -\tfrac{3}{2} + \tfrac{39}{8} - \tfrac{27}{8} \\[4pt] &= -\tfrac{12}{8} + \tfrac{39}{8} - \tfrac{27}{8} = 0 \end{aligned} $$ 代入 $f_3$: $$ \begin{aligned} f_3 &= 2 \cdot 1 - 3 \cdot 1^2 \cdot \left(-\tfrac{3}{2}\right) - 1^3 \cdot \tfrac{13}{2} \\[4pt] &= 2 + \tfrac{9}{2} - \tfrac{13}{2} \\[4pt] &= \tfrac{4}{2} + \tfrac{9}{2} - \tfrac{13}{2} = 0 \end{aligned} $$ $P_2 \mapsto (-\frac{1}{4}, 0, 0)$。 #### 验证 $P_3$:对称性的精妙 对于 $(-1, \frac{3}{2}, \frac{13}{2})$,$xy = -1 \cdot \frac{3}{2} = -\frac{3}{2}$,$1+xy = -\frac{1}{2}$,与 $P_2$ 相同。因此依赖 $u=1+xy$ 的项完全一致。 $f_1$(只依赖 $y^2$ 和 $u$,不依赖 $y$ 的符号): $$ f_1 = -\tfrac{13}{16} + \tfrac{9}{16} = -\tfrac{1}{4} \quad \text{(与 }P_2\text{ 完全相同)} $$ $f_2$($x=-1$ 导致关键项的符号翻转): $$ \begin{aligned} f_2 &= \tfrac{3}{2} + 3 \cdot (-1) \cdot \tfrac{1}{4} \cdot \tfrac{13}{2} + 3 \cdot (-1) \cdot \tfrac{9}{4} \cdot \left(-\tfrac{1}{2}\right) \\[4pt] &= \tfrac{3}{2} - \tfrac{39}{8} + \tfrac{27}{8} \\[4pt] &= \tfrac{12}{8} - \tfrac{39}{8} + \tfrac{27}{8} = 0 \end{aligned} $$ $f_3$($x=-1$,$x^2=1$,$x^3=-1$): $$ \begin{aligned} f_3 &= 2 \cdot (-1) - 3 \cdot 1 \cdot \tfrac{3}{2} - (-1) \cdot \tfrac{13}{2} \\[4pt] &= -2 - \tfrac{9}{2} + \tfrac{13}{2} \\[4pt] &= -\tfrac{4}{2} - \tfrac{9}{2} + \tfrac{13}{2} = 0 \end{aligned} $$ $P_3 \mapsto (-\frac{1}{4}, 0, 0)$。 **三个不同的点,全部映射到同一个像。** 这不光是"不单射":这是一个三对一的碰撞。没了单射性,多项式逆就不存在;多项式逆不存在,雅可比猜想就死了。 #### Lean 4 验证 > **本文的 Lean 4 代码由 AI 生成** 因为我不会 Lean,而且我也不想装 Mathlib 编译什么包,所以改用纯 Lean 4 内核求值器 `#eval` 进行数值验证。 以上验证过程可以用 Lean 4 形式化。把多项式映射定义清楚,然后用 `#eval` 交给内核求值器计算: ```lean title="验证三点碰撞 (Lean 4)" -- 多项式映射 F: ℚ³ → ℚ³ def F (x y z : Rat) : Rat × Rat × Rat := ((1 + x*y)^3 * z + y^2 * (1 + x*y) * (4 + 3*x*y), y + 3*x*(1 + x*y)^2 * z + 3*x*y^2 * (4 + 3*x*y), 2*x - 3*x^2*y - x^3*z) -- 三个不同的点,映射到同一个像 (-1/4, 0, 0) #eval F 0 0 (-1/4) #eval F 1 (-3/2) (13/2) #eval F (-1) (3/2) (13/2) ``` > `#eval` 是 Lean 4 的内核求值器,可以直接计算 `Rat`(有理数)的算术表达式。三个 `#eval` 的输出均为 `(-1/4, (0, 0))`。 ### 雅可比行列式为常数 $-2$ 要完整验证,还得确认雅可比行列式确实是常数 $-2$。雅可比矩阵 $J_F \in \mathbb{C}^{3 \times 3}$: $$ J_F = \begin{bmatrix} \frac{\partial f_1}{\partial x} & \frac{\partial f_1}{\partial y} & \frac{\partial f_1}{\partial z} \\[8pt] \frac{\partial f_2}{\partial x} & \frac{\partial f_2}{\partial y} & \frac{\partial f_2}{\partial z} \\[8pt] \frac{\partial f_3}{\partial x} & \frac{\partial f_3}{\partial y} & \frac{\partial f_3}{\partial z} \end{bmatrix} $$ 令 $u = 1+xy$,$v = 4+3xy$,逐项求导: $$ \begin{aligned} \frac{\partial f_1}{\partial x} &= 3y(1+xy)^2 z + y^3(7+6xy) \\[4pt] \frac{\partial f_1}{\partial y} &= 3x(1+xy)^2 z + 2y(1+xy)(4+3xy) + xy^2(7+6xy) \\[4pt] \frac{\partial f_1}{\partial z} &= (1+xy)^3 \\[8pt] \frac{\partial f_2}{\partial x} &= 3(1+xy)^2 z + 6xy(1+xy)z + 3y^2(4+3xy) + 9xy^3 \\[4pt] \frac{\partial f_2}{\partial y} &= 1 + 6x^2(1+xy)z + 6xy(4+3xy) + 9x^2y^2 \\[4pt] \frac{\partial f_2}{\partial z} &= 3x(1+xy)^2 \\[8pt] \frac{\partial f_3}{\partial x} &= 2 - 6xy - 3x^2 z \\[4pt] \frac{\partial f_3}{\partial y} &= -3x^2 \\[4pt] \frac{\partial f_3}{\partial z} &= -x^3 \end{aligned} $$ 这九个偏导代入 $3 \times 3$ 行列式公式后,几乎所有项都互相抵消。你可以在 SymPy、Mathematica 甚至让 GPT 帮你展开。结果是: $$ \det(J_F) = -2 $$ 一个非零常数,完美满足雅可比猜想的条件。但映射却不单射——**猜想被推翻。** #### Lean 4 验证 行列式恒等式同样可以丢给 Lean 验证。由于没装 Mathlib,无法使用 `Matrix.det`,AI 改用 3×3 行列式显式展开公式——本质一模一样: ```lean title="验证雅可比行列式恒为 -2 (Lean 4)" -- 雅可比行列式(3×3 展开公式,等价于 Matrix.det) def jacobianDet (x y z : Rat) : Rat := let u := 1 + x*y let a11 := 3*y*u^2*z + y^3*(7+6*x*y) let a12 := 3*x*u^2*z + 2*y*u*(4+3*x*y) + x*y^2*(7+6*x*y) let a13 := u^3 let a21 := 3*u^2*z + 6*x*y*u*z + 3*y^2*(4+3*x*y) + 9*x*y^3 let a22 := 1 + 6*x^2*u*z + 6*x*y*(4+3*x*y) + 9*x^2*y^2 let a23 := 3*x*u^2 let a31 := 2 - 6*x*y - 3*x^2*z let a32 := -3*x^2 let a33 := -x^3 a11*(a22*a33 - a23*a32) - a12*(a21*a33 - a23*a31) + a13*(a21*a32 - a22*a31) -- 在多个点验证行列式恒为 -2 #eval jacobianDet 0 0 0 #eval jacobianDet 1 2 3 #eval jacobianDet 1 (-3/2) (13/2) #eval jacobianDet (-1) (3/2) (13/2) ``` 以上四个 `#eval` 结果均为 `-2`。对于多项式恒等式,在足够多的点上验证等价于代数恒等式。如果装了 Mathlib,原教旨写法是用 `Matrix.det` + `native_decide` 做全称量化证明(`∀ x y z`),但手里没榔头不代表钉子敲不进去。 --- ## 这个反例有多"狗屎" 行,验证完了,来聊点更有趣的。说实话,这个反例的出现方式,本身就堪称数学史上的一个行为艺术。 ### 世界杯 + AI = 85年猜想终结者 85年来,世界上最聪明的数学家们——包括 Smale、Abhyankar、Mumford 这些泰斗级人物——前仆后继地尝试证明或推翻这个猜想,发表了无数论文。有人试图证明 $n=2$ 时猜想成立,有人试图推广到更高维。 结果呢?一个 AI 在**足球赛中场休息时**就把反例吐出来了。 你知道这意味着什么吗?Alpöge 甚至没有专门坐下来"做研究"——他只是在看球赛的间隙,出于无聊,随口问了 AI 一句。这在数学史上大概是前无古人的:**一个85年悬案,死于世界杯决赛夜的沙发消遣。** 或者说,这个反例的难度可能是某个大一学生就能**灵机一动**出来的,但时间等不及了,AI 给出来了。 | 人物/工具 | 贡献 | | ---------------------- | ------------------------ | | Keller (1939) | 提出猜想 | | Smale (1998) | 列入21世纪最重要数学问题 | | 全球数学家 (1939-2026) | 85年徒劳尝试 | | Levent Alpöge | 在世界杯决赛夜问了AI一句 | | Claude Fable | 几秒钟吐出反例 | ### 反例的"丑陋优雅" 仔细看一下这组多项式: - $f_1 = (1+xy)^3 z + y^2 (1+xy) (4+3xy)$ —— 嵌套的 $(1+xy)$,配上 $4+3xy$,像胡乱拼凑的 - $f_2 = y + 3x(1+xy)^2 z + 3 x y^2 (4+3xy)$ —— 在 $y$ 的基础上对称地贴了两块补丁 - $f_3 = 2x - 3x^2 y - x^3 z$ —— 简单得不像话,干净到只有三项 你说它"丑"吧,确实丑——没人会凭空写出这种多项式。你说它"美"吧,每一项都恰到好处地让 $3 \times 3$ 行列式中的巨量复杂项互相抵消,最终剩下一个干干净净的 $-2$。 | 维度 | 评价 | | -------- | ---------------------------- | | 外观 | 像把多项式随机搅拌后倒出来的 | | 结构 | 每一项都为抵消而生,严丝合缝 | | 验证难度 | 大一微积分水平 | | 发现难度 | 85年无人找到 | | 发现方式 | 世界杯决赛夜,AI 随手一算 | 这种"丑陋的优雅",说实话,非常符合 AI 的风格——它不在乎式子好不好看,只在乎能不能通过计算。 ### 三对一碰撞的设计感 最绝的是那三个碰撞点: $$ (0, 0, -\tfrac{1}{4}),\quad (1, -\tfrac{3}{2}, \tfrac{13}{2}),\quad (-1, \tfrac{3}{2}, \tfrac{13}{2}) $$ 注意 $P_2$ 和 $P_3$ 的对称性——$x$ 和 $y$ 的符号同时翻转,而 $f_1$ 中 $y$ 只以 $y^2$ 出现、$f_2$ 和 $f_3$ 中的符号翻转被精确抵消,于是这两个看起来"对称"的点被映射到了完全相同的像。 这种精巧的对称性,你要说是人设计出来的我信,你要说是 AI 暴力搜索出来的……我也信。毕竟对 AI 来说,"构造一个满足这些约束的多项式"就是把问题丢进搜索空间然后等几秒钟的事情。 --- ## 结语 AI 时代,“被吓到眩晕瘫坐在椅子上,那一刻就像看到原子弹爆炸” 这个不断在数学界发生,当然这个已经在计算机领域出现到令人无感了,但我想说AI的智力工程能力才刚刚开始…… --- ## 发布 @xingwangzhe/satteri-mermaid:剔除mermaid.js,用Rust原生渲染流程图,构建时产出纯静态SVG URL: https://xingwangzhe.fun/posts/satteri-mermaid-npm-package/ License: CC-BY-NC-SA-4.0 - [@xingwangzhe/satteri-mermaid](https://github.com/xingwangzhe/satteri-mermaid/) > 本文核心代码是Vibe Rust写的 在前文[ Astro: 优化katex,mermaid和灯箱使用](https://xingwangzhe.fun/posts/astro-optimize-katex-mermaid-photoswipe/)中,我已经通过构建时检测实现了 mermaid 的**按需加载**——只有包含流程图的页面才会 `import mermaid.js`。 但问题恰恰出在这里。 --- ## mermaid.js 有多重? 来,我们先看看客户端 mermaid.js 的体量: | 指标 | 数值 | 吐槽 | | -------------- | ----------------- | ---------------------------------------------------------- | | 包体积 | ~1.2MB (min+gzip) | 比我的整个博客首页还大 | | 初始化时间 | ~200-500ms | 用户看着白屏等你的流程图"画"出来 | | 布局抖动 | 严重 | 图表渲染前是空的,渲染后突然撑开页面,像极了 2003 年的网页 | | 对 JS 的依赖 | 100% | 不用 JS 就看不了图,搜索引擎也看不了 | | 对浏览器的要求 | 支持 ES6+ | 虽然 2026 年了这不算问题,但凭什么流程图需要 JS 运行? | > 用 1.2MB 的 JavaScript 渲染一张"Hello World"流程图——这是用火箭炮打蚊子。 说实话,对于一个**纯静态博客**而言,让客户端 JavaScript 来渲染流程图,本身就有点荒谬。这些图表在构建时就完全可以确定了——它们就是写死在 Markdown 里的 Mermaid 代码,变都不变的。我构建一次,生成静态 SVG,后面就永远不需要再碰 JS 了。 但当时我并没有合适的工具。直到我发现了 `mermaid-rs-renderer`——一个用 Rust 写的 Mermaid 渲染器,并决定把它包装成 Astro 插件。 这一路,跌跌撞撞走了三代。 --- ## 出土文物清单:satteri-mermaid 的演化史 在最终方案落地之前,我的 `@xingwangzhe/satteri-mermaid` 插件经历了三个大版本的更迭。说实话,每一代都是在上一代的坑里爬出来的。 | 文物编号 | 代际 | 渲染引擎 | 速度 | 图类型 | 体积 | 致命缺陷 | | -------- | --------- | ---------------------- | ------------ | ------ | --------------------- | ----------------------------------------------------------- | | 001 | v0.1-v0.2 | 客户端 mermaid.js | 慢(200ms+) | 不定 | 0(但用户承担 1.2MB) | 需要客户端 JS,SEO 不可见,布局抖动 | | 002 | v0.3-v0.4 | beautiful-mermaid (JS) | 中 | 有限 | ~500KB | JavaScript 渲染器,Node.js 构建时性能一般 | | 003 | v0.5.x | @mermanjs/web (WASM) | 中 | 24 | 8.8MB WASM | WASM 体积巨大,1px 边框硬编码不可调,初始化需要文件系统查找 | | 004 | v0.7.0 | mermaid-rs (napi-rs) | **3ms/图** | 23 | 原生 .node | 相比 WASM 少了 xychart(但这个图类型本身就很少用) | > 从 JavaScript 到 WASM 再到 napi-rs 原生绑定,这一路在优化。 --- ## 核心架构:MDAST + HAST 双插件 在 v0.2 版本之前,我只有一个简单的 MDAST 插件:检测到 ` ```mermaid` 代码块,直接把它原样输出到 HTML。然后让客户端 mermaid.js 来接手。 问题很快暴露了——Sätteri 处理器(本博客使用的 Markdown 处理器)在进行文本变换(smart punctuation 等)时,会**破坏 Mermaid 代码**。最典型的是 `{"` 这种花括号加引号的序列,会被 Sätteri 当成字符实体处理。结果就是渲染出来的流程图要么缺胳膊少腿,要么直接报错。 这个问题的根源在于:Mermaid 代码在 Markdown 的 AST(抽象语法树)层面还是一段"普通文本",会被后续的各种 Rehype/Hast 插件随意蹂躏。唯一的解决方案是——在 MDAST 阶段把 Mermaid 代码**隐藏起来**,等所有文本处理都结束了,再在 HAST 阶段还原并渲染。 这就是双插件架构的核心思路: ```mermaid flowchart TD A["Markdown 源码"] --> B["MDAST 插件:检测 mermaid 代码块"] B --> C["将代码存入 ctx.data,输出空占位符 <pre>"] C --> D["Sätteri 文本处理(占位符不被破坏)"] D --> E["HAST 插件:从 ctx.data 取出代码"] E --> F{"ssg 模式?"} F -->|"是"| G["napi-rs 渲染器 → 内联 SVG"] F -->|"否"| H["<pre class='mermaid'> 待客户端渲染"] ``` 这个过程的设计巧妙之处在于: | 阶段 | 做了什么 | 为什么重要 | | -------- | ------------------------------------------------------------------ | -------------------------------------------------------- | | MDAST | 输出空的 `
` 占位符,代码存入 `ctx.data` | 占位符在 Sätteri 看来就是普通 HTML,不会触发任何文本变换 |
| 中间处理 | Sätteri 执行 smart punctuation、GFM 等文本变换                     | Mermaid 代码已安全藏起,不被"误伤"                       |
| HAST     | 从 `ctx.data` 取出代码,调用渲染器生成 SVG 并替换占位符            | 渲染失败时自动回退为原始代码块,**不会导致页面空白**     |

---

## 三代渲染引擎的技术细节

### 第一代:客户端 mermaid.js(v0.1 - v0.2)

```typescript title="src/utils/remark-post-body.ts (v0.1-v0.2 简化版)"
// MDAST 插件:检测 mermaid 代码块,输出占位符
visit(tree, (node: any) => {
    if (node.type === "code" && node.lang === "mermaid") {
        node.type = "html";
        node.value = `
${node.value}
`; } }); ``` 然后在 Layout 里条件加载: ```astro title="src/layouts/PostLayout.astro (v0.1-v0.2)" {hasMermaid && ( )} ``` 这倒也不是一无是处——至少比全局加载 mermaid.js 好。但问题在于,**mermaid 依然是客户端的**。搜索引擎爬虫看不到你的流程图内容,RSS 阅读器里是一片空白,关掉 JS 的用户看到的是 `
graph TD\n A-->B
` 这样的原始代码。 ### 第二代:@mermanjs/web WASM(v0.3 - v0.5) 第一次尝试"构建时渲染"用的是 `beautiful-mermaid`(一个 JS 渲染器),很快换成了 `@mermanjs/web`——一个用 Rust 编译成的 WASM 模块。这比纯 JS 强了不少: | 特性 | beautiful-mermaid (JS) | @mermanjs/web (WASM) | | --------- | ---------------------- | -------------------- | | 图表类型 | 有限 | **24 种** | | 主题预设 | 少 | **7 种** | | 渲染方式 | JS 运行时 | **构建时出 SVG** | | 客户端 JS | 需要 | **零** | 但 WASM 方案有个很蛋疼的问题——**初始化特别重**: ```typescript title="src/renderer.ts (v0.5.x merman WASM 版本)" import { initMerman, renderSvg } from "@mermanjs/web"; import { existsSync, readFileSync } from "node:fs"; // 在 node_modules 里找 WASM 文件...这操作真的优雅吗? function findWasm(): string { let dir = process.cwd(); for (let i = 0; i < 20; i++) { const p = dir + "/node_modules/@mermanjs/web/pkg/merman_wasm_bg.wasm"; if (existsSync(p)) return p; const sep = dir.lastIndexOf("/"); if (sep <= 0) break; dir = dir.slice(0, sep); } throw new Error("Cannot find @mermanjs/web WASM"); } async function doInit(): Promise { const wasmPath = findWasm(); const wasmBytes = readFileSync(wasmPath); const wasmModule = await WebAssembly.compile(wasmBytes); await initMerman({ loader: ..., wasm: wasmModule }); } ``` > 在 `node_modules` 里逐级往上找 8.8MB 的 WASM 文件,然后 `readFileSync` 读进来,再 `WebAssembly.compile` 编译……这一套下来,构建启动时间直接多了几百毫秒。 而且最让人抓狂的是,merman 的边框宽度是**硬编码 1px**,没法自定义——在深色主题下,边框几乎看不见。 ### 第三代:napi-rs 原生绑定(v0.7.0)——终局之战 WASM 方案还有一个更深层的问题——它是一个"中间人"。Rust 代码 → 编译成 WASM → Node.js 加载 WASM → WebAssembly 运行时执行。每一步都是开销。 最优解是什么?**Rust 代码 → 编译成原生 .node 二进制 → Node.js 直接调用**。中间的 WASM 层,砍掉。 这就是 v0.7.0 做的事——用 napi-rs 把 `mermaid-rs-renderer` 包装成一个 Node.js 原生模块: ```rust title="src/lib.rs" use mermaid_rs_renderer::{render_with_options, RenderOptions as MrRenderOptions, Theme}; use napi_derive::napi; #[napi(object)] pub struct RenderOptions { pub theme: Option, pub primary_color: Option, pub primary_border_color: Option, // ... 60+ 参数全覆盖 } #[napi] pub fn render(code: String, opts: Option) -> napi::Result { let mr_opts = build_render_options(&opts); render_with_options(&code, mr_opts) .map_err(|e| napi::Error::from_reason(e.to_string())) } ``` JS 端直接调用,不再需要什么 WASM 文件查找的骚操作: ```typescript title="src/renderer.ts (v0.7.0 napi-rs 版本)" import { createRequire } from "node:module"; function getBinding(): NativeBinding { const require = createRequire(import.meta.url); return require("../index.cjs") as NativeBinding; } export function renderMermaidSVG(code: string, opts: Record): string { const { render } = getBinding(); return render(code.trim(), opts); } ``` 就这。不需要 `readFileSync`,不需要 `WebAssembly.compile`,不需要在 `node_modules` 里大海捞针。Node.js 的 `require` 直接加载 `.node` 二进制,Rust 函数像 JS 函数一样调用。 --- ## 效果对比 | 指标 | mermaid.js (客户端) | @mermanjs/web (WASM) | mermaid-rs (napi-rs) | | ---------------- | -------------------------- | ---------------------- | ------------------------------- | | 单图渲染时间 | 200-500ms(用户感知) | ~50ms(构建时) | **~3ms**(构建时) | | 首次初始化 | 0(已包含在渲染时间内) | ~300ms(WASM 编译) | **~0ms**(原生加载) | | 产物体积 | 0(但用户承担 1.2MB 下载) | 8.8MB WASM 文件 | **<1MB** .node 二进制 | | 客户端 JS | 需要 1.2MB+ | 0 | 0 | | 图类型支持 | 全面 | 24 | 23(少一个 xychart) | | 主题预设 | 5 | 7 | 5(但全部颜色可覆盖) | | 边框粗细控制 | 可配置 | **硬编码 1px,不可配** | 可配置(通过 theme overrides) | | 可自定义颜色字段 | 有限 | 16 种 role | **60+ 参数全覆盖** | | SEO 友好 | 否(爬虫看不到 SVG) | 是(内联 SVG) | 是(内联 SVG) | | RSS 阅读器友好 | 否(需要 JS) | 是(静态 SVG) | 是(静态 SVG) | | 平台支持 | 全平台 | 全平台 | Linux/Mac/Windows (x64 + arm64) | 表格最后一行需要说明一下——napi-rs 需要预编译平台二进制,目前我们覆盖了 Linux x64、Linux arm64、macOS arm64、Windows x64。对于小众平台(如 FreeBSD),可以回退到客户端 mermaid.js 模式(`ssg: false`),或者……自己编译(开源的好处)。 --- ## 在 Astro 中的使用 整个配置出奇地简单。在 `astro.config.mjs` 中: ```js title="astro.config.mjs" import { satteri } from "@astrojs/markdown-satteri"; import { mermaidMdast, mermaidHast } from "@xingwangzhe/satteri-mermaid"; export default defineConfig({ markdown: { processor: satteri({ mdastPlugins: [mermaidMdast()], hastPlugins: [ mermaidHast({ theme: "dark", responsive: true, themeOverrides: { clusterBorder: "#cccccc", primaryBorderColor: "#ff6600", }, }), ], }), }, }); ``` 然后你该写什么写什么: ````markdown ```mermaid flowchart TD A["开始"] --> B["处理"] B --> C["结束"] ``` ```` 构建后自动变成: ```html title="构建产物"
...
``` 零 JS,零依赖,纯静态。搜索引擎能直接索引 SVG 内的文字,RSS 阅读器能正常显示,手机端也不需要加载 1.2MB 的脚本来渲染一张图。 ### ssg 开关:构建时 vs 客户端,由你决定 说实话,我也不想把话说死——并不是所有人都适合纯静态 SVG。有些场景下,你可能就是想用客户端 mermaid.js 自己渲染: | 场景 | 说明 | | --------------------------------- | --------------------------------------------------------- | | 小众平台(FreeBSD、ARM 开发板等) | 没有预编译的 napi-rs 二进制,但 Node.js 跑得动 | | 使用了 `xychart` 图表 | mermaid-rs 目前支持的 23 种图表中不含 xychart | | 渐进迁移 | 旧的客户端 mermaid.js 方案先不动,新文章逐步切到 SSG 模式 | | 就是喜欢客户端渲染 | 没毛病,插件不强制任何选择 | 为此,插件提供了一个 **`ssg` 开关**: ```js title="astro.config.mjs — ssg: true(默认,构建时渲染)" mermaidHast({ ssg: true, // 构建时产出内联 SVG,零客户端 JS theme: "dark", }); ``` ```js title="astro.config.mjs — ssg: false(回退到客户端 mermaid.js)" mermaidHast({ ssg: false, // 输出
 代码块,交给客户端 mermaid.js 渲染
});
```

当 `ssg: false` 时,插件不会调用 napi-rs 渲染器,而是输出干净的 `
code
` 标签。你只需要在页面中引入 mermaid.js: ```html title="任意 Layout 或页面组件" ``` | 模式 | 适用场景 | 客户端 JS | SEO 友好 | 渲染时机 | | ------------ | -------------------------------- | --------- | -------- | ------------- | | `ssg: true` | 主流平台、SSG 博客、追求极致性能 | 0 | 是 | `astro build` | | `ssg: false` | 小众平台、需要 xychart、渐进迁移 | 需要 | 否 | 浏览器运行时 | > 这个开关的设计理念很明确:**你不需要在"全有"和"全无"之间二选一**。一台 x64 Linux 构建服务器上开 `ssg: true` 享受 3ms/图的速度;改天换到 ARM 开发板上照样能跑 `ssg: false` 不掉链子。进可攻,退可守。 --- ## 这一路踩过的坑 ### 坑 1:Sätteri 破坏 Mermaid 代码 这是最早的坑,也是逼出双插件架构的根本原因。Sätteri 的 smart punctuation 会把 Mermaid 代码中的特殊字符序列(比如 `{"`)当成文本进行处理变换,导致图渲染失败。 解决方案前面已经说了:MDAST 阶段把代码藏起来,HAST 阶段再取出来渲染。 ### 坑 2:WASM 文件查找 `@mermanjs/web` 的 WASM 文件位置在不同环境下可能不同(`npm link`、workspace、monorepo 都会影响路径)。写了一个逐级向上查找的函数,最多找 20 层目录。说实话,这种代码写出来我就知道这方案活不长。 用 napi-rs 之后,`.node` 文件跟着 npm 包走,Node.js 的 require 机制自动解析路径。天经地义的事,不需要任何骚操作。 ### 坑 3:Expressve Code 语法高亮器干扰 Astro 的 expressive-code 会把 ` ```mermaid` 代码块当成普通代码块处理,输出时加了一堆 `` 和 class。导致 HAST 插件找不到 mermaid 代码。 解决方案是在 HAST 插件中同时监听两种节点:`raw` 节点(原生的 HTML 输出)和 `element` 节点(被语法高亮器处理后的结构化输出)。双路径兜底,确保万无一失。 --- ## 总结 现在回头看,这个问题的解决路径其实非常清晰: | 步骤 | 问题 | 答案 | | ---- | ------------------ | ---------------------------------------------- | | 1 | 流程图源码在哪? | Markdown 中就已确定,无需浏览器介入 | | 2 | 什么时候渲染? | 构建时渲染成 SVG,而非客户端运行时 | | 3 | 用什么渲染? | JavaScript 太慢,WASM 太重,**原生绑定刚刚好** | | 4 | 怎么保证不被打扰? | MDAST 藏代码,HAST 取回并渲染 | 最终,我们用 **150 行 Rust** + **450 行 TypeScript** 的代码量,换来了: | 指标 | 优化前 | 优化后 | | -------------- | --------------------- | --------------------- | | 客户端 JS 体积 | 1.2MB | **0** | | 流程图渲染延迟 | 200-500ms(用户感知) | **0(构建时已完成)** | | 搜索引擎可见性 | 看不到 SVG 内容 | **完全可索引** | | 构建时单图渲染 | 不适用(客户端渲染) | **稳定在 3ms 以内** | 说实话,这种"在构建时就把所有事情做完"的理念,才是一个静态博客该有的样子。你的内容是什么,就生成什么——不需要用户在浏览器里再去编译、渲染、计算。 > 流程图不应该是一个运行时概念——它应该是一张图片。在你按下 `Ctrl+S` 的那一刻,它就应该是图片了。 --- ## 安装使用 ```bash title="安装" npm install @xingwangzhe/satteri-mermaid ``` 依赖 `satteri >= 0.8.0`。无需其他运行时依赖——napi-rs 渲染器已内置在各平台的 `.node` 二进制中。开箱即用。 --- **相关链接:** - [@xingwangzhe/satteri-mermaid](https://github.com/xingwangzhe/satteri-mermaid) — npm 包源码 - [mermaid-rs-renderer](https://github.com/1jehuang/mermaid-rs-renderer) — 底层 Rust 渲染器 - [Astro: 优化katex,mermaid和灯箱使用](https://xingwangzhe.fun/posts/astro-optimize-katex-mermaid-photoswipe/) — 上一阶段的优化记录 --- ## 人工智能实训结题报告:B4 Agent LLM决策模块 —— 从架构设计到五维进阶 URL: https://xingwangzhe.fun/posts/ai-training-b4-final-report/ License: CC-BY-NC-SA-4.0 > 本文为综合实训Ⅱ阶段个人结题技术报告,原为校内验收文档,现整理为博客公开版本。对团队其他成员姓名做了打码处理。 > > **个人 GitHub 仓库**:[https://github.com/xingwangzhe/B4-Agent-LLM](https://github.com/xingwangzhe/B4-Agent-LLM) > > **团队合并仓库**:[https://github.com/woaiwang/Voice-Agent/](https://github.com/woaiwang/Voice-Agent/) > > 实训系列博文:[Day1 环境搭建](https://xingwangzhe.fun/posts/ai-training-ubuntu-conda-day1/) → [Day2 SFT与DPO](https://xingwangzhe.fun/posts/ai-training-sft-dpo-day2/) → [Day3 Agent实践](https://xingwangzhe.fun/posts/ai-training-agent-day3/) → [Day4 Proposal](https://xingwangzhe.fun/posts/ai-training-agent-day4-proposal/) → [Week2 五维升级](https://xingwangzhe.fun/posts/ai-training-b4-llm-week2/) → [验收讲解](https://xingwangzhe.fun/posts/ai-training-b4-week2-review/) --- ![实习证明](/AI/signature.webp) ## 一、 项目与团队基本信息 - **本人姓名**:王兴家 - **本人学号**:2023xxxx - **项目名称**:Agent智能体系统(方向B) - **实际完成目标**:包含进阶挑战项(完成了 5 项进阶功能:①单轮多 tool_calls 与多 ToolMessage ②Plan-and-Execute 模式 ③多模型切换 ④tools_schema 传参方式对比(prompt 注入 vs 内置传参)⑤批量测试 + 工具调用成功率/Token 统计) - **小组其他成员**:查同学、张同学、计同学 ### 成员最终分工与交付核对表 | 角色 | 姓名 | 学号 | 实际负责的核心模块名称 | 个人代码库链接 | | :--: | :--------: | :----------: | :--------------------------: | :------------------------------------------------: | | 组长 | 查同学 | 2023xxxx | B1 - Agent运行与消息管理模块 | | | 组员 | 张同学 | 2023xxxx | B3 - 说明生成与工具调用模块 | | | 组员 | 计同学 | 2023xxxx | B2 - Skill工具函数模块 | | | 组员 | **王兴家** | **2023xxxx** | **B4 - Agent LLM决策模块** | | --- ## 二、 整体系统架构与最终成果展示 ### 2.1 最终系统总体架构图 下图展示了本项目五个模块(B1-B5)在系统中的物理位置与数据流向: ```mermaid graph TD subgraph 用户层 User[("👤 用户")] end subgraph 运行时编排层 B1["B1 Agent Runtime
运行与消息管理"] end subgraph 决策与记忆层 B4["B4 LLM Decision
模型决策模块"] B5["B5 Memory
记忆文档存储与查找"] end subgraph 工具桥接与执行层 B3["B3 Tool Layer
说明生成与工具调用"] B2["B2 Skill
工具函数实现"] end subgraph 基础设施 CFG["配置文件
model.yaml
tools.yaml
memory.yaml"] LLM["本地模型
Qwen3.5-4B"] end User -->|"自然语言指令
runtime_input.json"| B1 B1 -->|"① 查询记忆"| B5 B5 -->|"记忆上下文"| B1 B1 -->|"② 请求工具说明"| B3 B3 -->|"tools_schema"| B1 B1 -->|"③ messages + schema
generate_ai_message()"| B4 B4 -->|"AIMessage
(content/tool_calls)"| B1 B4 -->|"加载推理"| LLM B1 -->|"④ 执行tool_calls"| B3 B3 -->|"动态加载调用"| B2 B2 -->|"SkillResult"| B3 B3 -->|"ToolMessage"| B1 B1 -->|"⑤ 保存记忆"| B5 B1 -->|"最终回答"| User B1 -->|"读取配置"| CFG B3 -->|"读取配置"| CFG B4 -->|"读取配置"| CFG B5 -->|"读取配置"| CFG ``` **模块职责说明:** | 模块 | 角色 | 核心职责 | | :-------------------- | :----------- | :------------------------------------------------------------------------------------------------------------------------------------------------------ | | **B1**(组长-查同学) | 运行时编排层 | 唯一编排者与消息中枢,基于`messages[-1]`角色驱动三状态ReAct循环(准备输入→调用LLM→执行工具),支持断点恢复、上下文压缩、多轮输入与动态System Prompt切换 | | **B2**(计同学) | 工具函数层 | 实现5个基础Skill:`calculator`、`file_reader`、`local_file_search`、`table_analyzer`、`format_converter`,通过`importlib`动态加载、`inspect`签名注入 | | **B3**(张同学) | 工具桥接层 | 将B2函数转为OpenAI function calling格式schema,校验tool_call参数合法性,动态加载执行并返回ToolMessage,支持IO异常有限重试 | | **B4**(王兴家) | 认知决策层 | 唯一与大模型交互的模块,加载本地Qwen3.5-4B,接收messages+tools_schema,输出AIMessage。双保险Prompt策略+三层级联解析,content优先修复互斥冲突 | | **B5** | 记忆系统层 | 基于Markdown文件+JSON索引的本地记忆系统,支持按ID检索+全局记忆合并+`max_memory_chars`截断 | ### 2.2 系统整体运行流程与集成说明 #### 三阶段 ReAct 闭环 系统整体遵循 ReAct(Reasoning + Acting)范式,由 B1 运行时编排器驱动一个完整的三阶段闭环: ```mermaid sequenceDiagram participant U as 用户 participant B1 as B1 Runtime participant B5 as B5 Memory participant B3 as B3 Tool Layer participant B4 as B4 LLM决策 participant B2 as B2 Skill Note over B1,B5: 阶段 0:初始化 B1->>B5: 查询历史记忆 B5-->>B1: 返回记忆上下文文档 B1->>B3: 请求工具说明 B3-->>B1: 返回 OpenAI 格式 tools_schema loop ReAct 循环(直至无 tool_calls 或达 max_turns) Note over B1,B4: 阶段 1:LLM 决策 B1->>B4: generate_ai_message(messages, tools_schema) Note over B4: Prompt注入 → 模型推理 → 输出解析 B4-->>B1: AIMessage(content 或 tool_calls) alt 有 tool_calls Note over B1,B2: 阶段 2:工具执行 B1->>B3: execute_tool_calls(tool_calls) B3->>B2: 动态加载并调用 Skill 函数 B2-->>B3: SkillResult(status + output) B3-->>B1: ToolMessage(结果封装) B1->>B1: 追加 ToolMessage 到 messages else 无 tool_calls(content 非空) Note over B1: 阶段 3:输出结果 B1->>U: 返回最终回答 end end Note over B1,B5: 收尾:记忆保存 B1->>B5: save_memory(对话记录) ``` **详细执行流程:** 1. **初始化阶段**:B1读取`runtime_input.json`,加载系统提示词。调用B5检索历史记忆并注入到messages中。调用B3读取`tools.yaml`生成`tools_schema.json`(OpenAI function calling格式)。 2. **ReAct 循环**(状态机驱动): - **决策步**(Stage 1):当messages末尾为user或tool时,B1调用B4的`generate_ai_message(messages, tools_schema)`。B4加载本地Qwen3.5-4B模型进行推理,输出AIMessage(含tool_calls或content)。若返回content(无tool_calls),则结束循环输出最终回答。 - **工具步**(Stage 2):当AIMessage含tool_calls时,B1调用B3的`execute_tool_calls()`,B3解析tool_call参数、动态加载B2对应的Skill函数并执行,返回封装好的ToolMessage。B1将其追加到messages,检查是否达到`max_turns`,未达到则返回决策步。 - **状态持久化**:每次状态变更后,B1将messages、trace、final_answer实时写入磁盘,支持断点恢复。 3. **收尾阶段**:生成最终答案`final_answer.md`,调用B5.save_memory()将本次对话保存为记忆。 #### 模块间接口契约 所有模块采用统一的JSON数据格式(基于OpenAI chat completions标准): | 消息类型 | 关键字段 | 用途 | | :---------- | :------------------------------------------------------------------ | :----------------------- | | AIMessage | `role: "assistant"`, `content`, `tool_calls: [{id, name, args}]` | B4输出,B1据此判断下一步 | | ToolMessage | `role: "tool"`, `tool_call_id`, `name`, `content`(SkillResult JSON) | B3输出,封装工具执行结果 | | SkillResult | `skill_name`, `status`, `input`, `output`, `error`, `latency_ms` | B2输出,B3消费 | #### 集成过程中遇到的问题与解决 在模块合并联调时,主要遇到了以下问题: 1. **JSON字段不一致**:初始联调时B1以OpenAI格式传递messages,但部分字段名和嵌套结构与B4/B3预期不匹配。最终通过统一定义`common/schemas.py`中的`make_ai_message`、`validate_ai_message`、`validate_messages`三个核心函数,对所有模块间的数据交换进行严格校验,解决了此问题。 2. **B4与B3数据格式分歧**:B4输出的tool_calls中工具名称使用"name"字段,但B3在动态加载Skill时需要"function.name"格式。通过在B3侧增加字段归一化层,自动兼容两种命名风格。 3. **Mock模式与真实模型的行为差异**:在Mock模式下联调通过的全部场景,切换到真实Qwen3.5-4B模型后出现了模型输出解析失败的情况。最终通过增强B4的JSON解析从严格互斥改为静默修复后,切换回真实模型时部分场景从失败变为成功。 4. **B1断点恢复的"坏消息回滚"机制**:联调中发现当LLM解析失败时,messages中残留的无效assistant消息会导致后续重试失败。B1实现了自动检测与回滚——若trace状态为`llm_parse_error`且末尾角色为assistant,则自动弹出该消息并重试。 ### 2.3 最终产品展示 (Demo) 下图展示了 **Voice Agent 全链路闭环**:用户通过语音输入"什么是智能体?",系统经过语音识别 → Agent 运行时编排 → LLM 决策 → 工具调用,最终输出关于智能体定义、特征和应用的详细回答,完整覆盖 B1→B4→B3→B2→B5 全模块链路: ![Voice Agent 全链路闭环:语音输入"什么是智能体",系统输出详细回答](/AI/B_system_voice_agent_full_answer.webp) 下图展示了**动态 System Prompt 切换**功能:Round 1 使用默认身份计算 16+16,Round 2 动态切换为"古文风格"身份后追问"刚才的结果是多少?",模型以"十加十得二十,此乃算术之常理"作答,验证了系统运行时的动态上下文切换能力: ![动态 System Prompt 切换:Round1 默认身份 → Round2 切换古文风格](/AI/B_system_dynamic_prompt_switch.webp) ### 2.4 团队系统代码库 - **团队 Github 开源仓库链接**: --- ## 三、 个人核心模块技术报告(B4 - Agent LLM决策模块) ### 3.1 模块定位与系统融合方式 - **在系统中的角色**:B4(LLM 决策模块)是整个 Agent 系统的**唯一与大模型交互的模块**,相当于系统的"大脑 / 决策中枢"。它把 B1 编排器给过来的对话上下文,转化为结构化决策——要么是需要调用工具的 `tool_calls`,要么是直接回复用户的 `content`。没有 B4,系统就无法把用户自然语言指令转成可执行的决策,B1 的编排循环与 B3 的工具执行都无从驱动。 - **上下游依赖与接口协同**: - **上游(输入)**:由 **B1 运行时编排器**调用 `generate_ai_message(messages, tools_schema)`。接收两类数据: - `messages`:OpenAI chat 格式消息序列(含 system / user / assistant / tool 四种角色),由 B1 维护; - `tools_schema`:由 **B3** 根据 `tools.yaml` 生成的 OpenAI function calling 格式工具说明。 - **下游(输出)**:返回标准 `AIMessage`(`{"role":"assistant","content":..., "tool_calls":[{"id","name","args"}]}`)给 **B1**。B1 据此判断:有 `tool_calls` 则交给 B3 执行→`ToolMessage`→追加回 messages→再次调用 B4;无 `tool_calls`(content 非空)则输出 `final_answer`,结束循环。 - **接口契约**:模块对外严格遵守团队统一的数据契约——通过 `common/schemas.py` 的 `make_ai_message` / `validate_ai_message` / `validate_messages` 对 `messages`、`AIMessage`、`ToolMessage`、`SkillResult` 进行构造与校验,从根源上避免了 B1/B3/B4 之间 JSON 字段不一致的问题。 - **输入输出格式示例**: ```json title="B4 输入输出格式示例" # B4 接收的输入 (messages) [ {"role": "system", "content": "你是本地工具调用Agent..."}, {"role": "user", "content": "帮我阅读docs/agent_intro.txt,总结三条中文要点"} ] # 同时接收 tools_schema (OpenAI function calling 格式): [ { "type": "function", "function": { "name": "file_reader", "description": "读取本地UTF-8文本或Markdown文件", "parameters": { "type": "object", "properties": { "path": {"type": "string", "description": "文件路径"}, "max_chars": {"type": "integer", "description": "最大读取字符数"} }, "required": ["path"] } } } ] # B4 返回的 AIMessage (决策调用工具): { "content": "", "tool_calls": [ {"id": "call_001", "name": "file_reader", "args": {"path": "docs/agent_intro.txt", "max_chars": 2000}} ] } # B4 返回的 AIMessage (工具结果后直接回答): { "content": "三条要点:\n1. Agent智能体系统由模型、工具、记忆和执行循环四个核心部分组成...", "tool_calls": [] } ``` ### 3.2 核心技术实现路径 #### 技术栈选型 | 类型 | 名称 | 用途 | | :----------- | :------------------------------ | :--------------------------------------------------------- | | 语言/运行时 | **Python 3.10** | 模块开发,类型提示完善 | | 基础模型 | **Qwen3.5-4B-Instruct** | 本地 LLM 推理,4B 参数,中文能力强 | | 推理框架 | **HuggingFace Transformers** | 模型加载与推理(`AutoModelForCausalLM` + `AutoTokenizer`) | | 深度学习后端 | **PyTorch 2.x (CUDA)** | 模型推理后端,`bfloat16` 精度,`device_map="auto"` | | 配置解析 | **PyYAML** | `model.yaml` 解析,代码与配置分离 | | 数据序列化 | **json(标准库)** | 输入输出标准化与落盘持久化 | | 解码策略 | **`do_sample=false`** | 贪心解码,确保工具调用输出的确定性 | | 模型路径 | `/root/assignment_B/Qwen3.5-4B` | 本地离线模型,`local_files_only=true` | #### 算法与工程实现 - **模型与框架**:基于本地部署的 **Qwen3.5-4B**(HuggingFace `transformers` 加载,`torch_dtype=bfloat16`,`device_map="auto"`,`max_new_tokens=1024`,`do_sample=False`),不依赖任何外部 API,全过程离线推理。`model.yaml` 配置了两个命名 profile:`qwen-4b`(标准模式)和 `qwen-4b-fast`,通过 `--model_name` 动态切换。 - **双保险 Prompt 策略(关键设计一)**:`_build_prompt_messages()` 在 System 消息里注入完整的"输出格式契约"(强制单一 JSON `{content, tool_calls}`、禁止 Markdown / 反引号 / 解释文本),并在**最后一条 user / tool 消息**尾部追加 `envelope_reminder` 二次强化约束,显著提升小模型稳定输出可解析 JSON 的概率。核心代码实现如下: ```python title="_build_prompt_messages() — 双保险Prompt策略" def _build_prompt_messages(messages, tools_schema): prompt_messages = deepcopy(messages) # 第一保险:System Message 中注入完整格式说明 format_instruction = ( "IMPORTANT OUTPUT FORMAT:\n" "You must return exactly one valid JSON object.\n" "Do not output markdown / explanations / code fences.\n" 'Valid schema A (final answer): {"content":"...","tool_calls":[]}\n' 'Valid schema B (tool call): {"content":"","tool_calls":[{"id":"...",...}]}\n' "You may include zero, one, or multiple tool_calls." ) system_instruction = "\n\nAvailable tools:\n" + json.dumps(tools_schema, ensure_ascii=False) + "\n" + format_instruction if prompt_messages and prompt_messages[0].get("role") == "system": prompt_messages[0]["content"] += system_instruction else: prompt_messages.insert(0, {"role": "system", "content": system_instruction.strip()}) # 第二保险:追加到最后一个 User Message envelope_reminder = "IMPORTANT: Output the JSON object now. First char must be '{', last must be '}'. No backtick/Markdown." for message in reversed(prompt_messages): if message.get("role") == "user": message["content"] += "\n\n" + envelope_reminder break # ToolMessage 特殊处理:自动追加引导 prompt if prompt_messages and prompt_messages[-1].get("role") == "tool": tool_count = sum(1 for m in reversed(prompt_messages) if m.get("role") == "tool") prompt_messages.append({"role": "user", "content": envelope_reminder + f" The last {tool_count} ToolMessage(s) contain results. If info is sufficient, answer with schema A now."}) return prompt_messages ``` - **三层级联 JSON 解析(关键设计二)**:真实 Qwen3.5-4B 的输出并不总是一段干净 JSON,常出现前后夹杂解释文本、尾随反引号、甚至 `tool_calls` 数组被截断等情况。为此实现了 `_parse_model_output()` 的级联兜底: 1. `json.loads` 严格解析(最快路径); 2. `_extract_json_object` 逐字符扫描,定位第一个合法 JSON 对象; 3. `_parse_json_with_backtick_tail` 处理尾随 `` ` `` 的情况; 4. `_parse_tool_calls_fragment` 兜底解析残缺的 `tool_calls` 数组。 - **content / tool_calls 互斥归一化(关键设计三)**:在 `_candidate_to_message()` 中,若模型同时给出 content 与 tool_calls,按"**content 优先**"原则清空 tool_calls,避免"既回答又调用工具"的冲突(这是 Mock→真实模型切换后大量场景从失败变成功的关键修复)。 - **模型缓存单例(关键设计四)**:模块级全局缓存 `_MODEL_CACHE`,以模型路径 / 精度 / device_map / local_only 等 7 个参数的元组为 key 缓存 `(tokenizer, model)`,同进程内重复调用直接命中缓存(约 237ms 加载 vs 首次 7-10s),批量测试时收益尤为显著。切换 `--model_name` 时路径不同自动 cache miss,无需手动清理: ```python title="_MODEL_CACHE — 模型缓存单例" _MODEL_CACHE: dict[tuple[str, ...], tuple[Any, Any]] = {} def _load_model_bundle(auto_model, auto_tokenizer, model_path, tokenizer_path, **kwargs): cache_key = (str(model_path), str(tokenizer_path), str(kwargs.get("local_only")), str(kwargs.get("trust_remote_code")), str(kwargs.get("dtype")), str(kwargs.get("device_map")), str(kwargs.get("max_memory"))) cached = _MODEL_CACHE.get(cache_key) if cached is not None: return cached # cache hit tokenizer = auto_tokenizer.from_pretrained(...) model = auto_model.from_pretrained(...) _MODEL_CACHE[cache_key] = (tokenizer, model) return tokenizer, model ``` - **Mock 模式(关键设计五)**:`_mock_generate()` 在无 GPU/模型环境下提供确定性输出,支持 CI 测试和无 GPU 联调。首次调用模拟并发返回 `file_reader` + `local_file_search` 两个 `tool_calls`;获取 ToolMessage 后根据执行结果分支——全部成功则汇总为三条中文要点,任一失败则返回错误消息。确定性输出使集成调试迭代速度大幅提升。 - **关键代码逻辑**:下面两段最能体现本模块的工作量与技术思考。 **① 三层级联容错解析(保证任何脏输出都能尽量解析出决策)** ```python title="_parse_model_output() — 三层级联容错解析" def _parse_model_output(raw_text: str) -> tuple[dict, dict]: try: candidate = json.loads(raw_text.strip()) except json.JSONDecodeError as exc: # 尝试从脏文本中提取第一个合法 JSON 对象 extracted = _extract_json_object(raw_text) if extracted is not None: try: return _candidate_to_message(extracted) except Exception: pass # 依次尝试:尾随反引号剥离 → tool_calls 残缺数组兜底 try: candidate = _parse_json_with_backtick_tail(raw_text, exc) except json.JSONDecodeError: candidate = _parse_tool_calls_fragment(raw_text, exc) return _candidate_to_message(candidate) ``` **② content 优先于 tool_calls 的互斥归一化(静默修复冲突)** ```python title="_candidate_to_message() — content优先互斥归一化" content = candidate.get("content", "") tool_calls = candidate.get("tool_calls", []) # 规范化:两者都非空时,优先使用 content,清空 tool_calls if content and tool_calls: print("⚠️ 警告:模型同时提供了 content 和 tool_calls,已忽略 tool_calls,使用 content 作为最终回答。", file=sys.stderr, flush=True) tool_calls = [] # 清空,使最终回答优先 message = {"role": "assistant", "content": content, "tool_calls": tool_calls} ``` - **进阶挑战攻克(5 项全部完成)**: #### 进阶①:单轮多 tool_calls 与多 ToolMessage **问题**:原始 Prompt 约束为"choose exactly one tool",每轮只能调用一个工具,无法处理需同时读取多个文件或并发执行多个计算的场景。 **方案**:将 Prompt 中的单工具约束改为"zero, one, or multiple";`_candidate_to_message()` 支持解析任意数量的 `tool_calls` 数组;`_mock_generate()` 模拟并发输出;提供 3 个和 5 个并发的极限测试数据。 **效果**:真实 Qwen3.5-4B 的 `case_multi_tool` 场景成功单轮生成 **3 个并发 tool_calls**(`file_reader` + `calculator` + `local_file_search`),显著减少多步骤任务轮次交互。 #### 进阶②:Plan-and-Execute 模式 **问题**:标准 ReAct 模式每步只做一次决策,缺乏全局规划,复杂任务易陷入局部最优。 **方案**:设计双阶段流程——Phase 1 生成结构化计划(`reasoning` + `plan` 数组),Phase 2 按计划逐步执行。新增 4 个核心函数:`_build_plan_prompt_messages()`、`_parse_plan_output()`、`_build_plan_step_prompt_messages()`、`_mock_plan_execute()`。 **效果**:真实 Qwen3.5-4B 模型成功输出 2 步以上的完整计划,Agent 从"走一步看一步"升级为"先规划后执行"。 #### 进阶③:多模型切换 **问题**:`model.yaml` 只支持单模型配置,切换模型需手动修改配置文件路径。 **方案**:在 `model.yaml` 中新增 `models` 命名配置段,预设两个 profile。`_load_model_config()` 增加 `model_name` 参数,CLI 增加 `--model_name` 选项,修改仅约 20 行代码。模型缓存 `_MODEL_CACHE` 天然支持多模型——不同模型路径生成不同 cache key。 **效果**:一行命令 `--model_name qwen-4b-fast` 即可切换模型配置。 #### 进阶④:tools_schema 传参方式对比 **问题**:`tools_schema` 既可通过 Prompt 文本注入(prompt_json),也可通过 HuggingFace 的 `apply_chat_template(tools=...)` 内置传参(builtin)。哪种方式对 Qwen3.5-4B 更有效? **方案**:设计 A/B 对比实验:A 组(prompt_json)将 tools_schema 嵌入 system message 文本,引导模型输出纯 JSON;B 组(builtin)通过 `tokenizer.apply_chat_template(tools=tools_schema, ...)` 传递。 **结果**: | 模式 | 成功率 | 平均延迟 | 解析失败原因 | | :-------------- | :------------: | :------: | :------------------------------------------------ | | **prompt_json** | **100%** (6/6) | 7087.5ms | — | | **builtin** | **0%** (0/6) | ~4094ms | Qwen3.5-4B 输出原生 XML 格式,JSON 解析器无法处理 | **效果**:prompt 注入方式以显著优势胜出,最终确定 `prompt_json` 为默认模式。 #### 进阶⑤:批量测试 + 成功率 / Token 统计 **问题**:单场景手动测试效率低,无法系统评估模型在不同场景下的工具调用能力。 **方案**:实现 `b4_batch_benchmark.py` 自动化评测框架,核心能力: - 从 `--cases_dir` 扫描所有测试用例 JSON 文件(`bench_cases/` 目录下 6 个场景) - 对每个用例调用 `generate_ai_message()` 并收集状态、延迟、工具调用数 - 输出汇总统计(成功率、平均延迟、每用例详情)和 `benchmark_summary.json` - 提供 `--tool_calling` 参数切换 prompt_json / builtin 模式进行对照 6 个测试用例设计如下: | 用例名 | 对应场景 | 预期行为 | | :-------------------- | :--------------------------- | :-------------------------- | | `case_calculator` | 计算表达式 `3.14 * 5 - 2.5` | 直接回答或调用 calculator | | `case_direct_answer` | 无需工具的直接问答 | 输出 content,tool_calls=[] | | `case_file_read` | 调用 file_reader 读取文件 | 单 tool_call | | `case_file_search` | 调用 local_file_search 搜索 | 单 tool_call | | `case_multi_tool` | 3 个工具并发调用 | 3 个并发 tool_calls | | `case_table_analyzer` | 调用 table_analyzer 分析 CSV | 单 tool_call | **效果**:一键运行 6 场景自动化测试,成功率和延迟数据精确可复现。 ### 3.3 最终结果与性能评估 #### 测试方法 采用**三种测试方法**相结合的策略: 1. **场景化测试**:设计 3 个核心场景(初始 tool_call 生成、基于 ToolMessage 的最终回答、工具调用失败处理),分别用真实模型和 Mock 模式验证。 2. **批量基准测试**:`b4_batch_benchmark.py` 遍历 `bench_cases/` 下 6 个测试用例,覆盖正常、异常、边界、并发等场景,自动统计成功率和平均延迟。 3. **Mock 模式验证**:所有测试用例在 Mock 模式下均达到 100% 成功率,确保代码逻辑正确性独立于模型能力。 #### 结果分析 - **`prompt_json` 模式(真实模型,本次实测)**:在远程 GPU 服务器(NVIDIA H200 NVL)上用本地 **Qwen3.5-4B** 实跑 `b4_batch_benchmark.py`,**6 个场景全部成功,解析成功率 100%(6/6)**,平均延迟 **7087.5 ms**(单 case 范围 2282.5–14963.1 ms)。各场景明细如下: | 测试场景 | 状态 | 延迟(ms) | tool_calls | 说明 | | :-------------------- | :--------: | :---------: | :--------: | :--------------------------------------- | | `case_calculator` | ✅ success | 10296.6 | 0 | 直接输出 content(content 优先策略生效) | | `case_direct_answer` | ✅ success | 2758.8 | 0 | 直接回答,无需工具 | | `case_file_read` | ✅ success | 2282.5 | 1 | 调用 file_reader 读取文件 | | `case_file_search` | ✅ success | 8201.7 | 1 | 调用 local_file_search 搜索文本 | | `case_multi_tool` | ✅ success | **14963.1** | **3** | **单轮生成 3 个并发 tool_calls** | | `case_table_analyzer` | ✅ success | 4022.5 | 1 | 调用 table_analyzer 分析表格 | - **`builtin` 模式(对照)**:历史对照测试显示解析成功率为 0%——原因是 Qwen3.5-4B 在 `apply_chat_template(tools=)` 下输出 **XML 格式**而非 JSON,现有解析器仅兼容 JSON。 - **过程可追溯**:每次推理都落盘 `raw_model_output.json`(模型原始输出)+ `ai_message.json`(解析后标准格式)+ `llm_run_log.jsonl`,便于复现与对比。 #### 指标汇总 | 评估维度 | 结果 | | :--------------------- | :--------------------------------------------------- | | 基础模块功能 | ✅ 全部完成 | | 进阶①-多工具并发 | ✅ 支持 3-5 并发 tool_calls | | 进阶②-Plan-and-Execute | ✅ 双阶段设计,真实模型验证通过 | | 进阶③-多模型切换 | ✅ `--model_name` 参数支持,代码修改 ≤ 20 行 | | 进阶④-Schema 对比 | ✅ prompt_json **100%** vs builtin 0% | | 进阶⑤-批量基准 | ✅ 6 用例自动化框架 | | 模块间联调 | ✅ 通过统一 `common/schemas.py` 解决 JSON 字段不一致 | | 真实模型解析成功率 | **100%** (6/6, prompt_json 模式) | | 平均推理延迟 | **7087.5 ms**(范围 2282.5–14963.1 ms) | **批量基准测试实测截图(`prompt_json` 模式,真实 Qwen3.5-4B):** ![B4 批量基准测试:运行过程与汇总](/AI/B4_benchmark_summary.webp) ![B4 批量基准测试:6 个场景明细](/AI/B4_benchmark_6cases_detail.webp) **某次真实运行的 `ai_message.json` 截图(单轮 3 并发 tool_calls):** ![B4 真实运行解析出的 AIMessage(单轮 3 并发 tool_calls)](/AI/B4_aimessage_multi_tool.webp) **进阶功能② Plan-and-Execute 模式运行截图:** ![B4 Plan-and-Execute 模式运行结果](/AI/B4_plan_execute_result.webp) **完整闭环 Demo 运行截图:** ![B4 完整闭环 Demo 运行结果](/AI/B4_full_demo_result.webp) ### 3.4 个人交付物清单 - **个人模块源码仓库**: --- ## 四、 实训总结与心得体会 > 本次实训的完整过程已以系列博文形式记录在个人博客上,从 Day1 到最终验收共 6 篇: > [Day1 Ubuntu与Conda环境搭建](https://xingwangzhe.fun/posts/ai-training-ubuntu-conda-day1/) → > [Day2 SFT与DPO对齐实践](https://xingwangzhe.fun/posts/ai-training-sft-dpo-day2/) → > [Day3 Agent工具调用与多技能协作](https://xingwangzhe.fun/posts/ai-training-agent-day3/) → > [Day4 Proposal设计](https://xingwangzhe.fun/posts/ai-training-agent-day4-proposal/) → > [Week2 B4五维升级实战](https://xingwangzhe.fun/posts/ai-training-b4-llm-week2/) → > [验收讲解:从基础到进阶的五维实践](https://xingwangzhe.fun/posts/ai-training-b4-week2-review/) ### 4.1 个人实训收获与挑战 - **实训历程回顾**:这次实训从零开始,三周内走完了"环境搭建→模型训练→Agent开发→模块设计→进阶升级→系统集成"的完整链路。 Day1 在 H200 GPU 服务器上用 Miniconda 搭建 `ai_infer` 推理环境,从 `pip install torch` 到跑通第一个矩阵乘法 `x @ w`——这个最简单的线性变换,就是所有大模型推理的底层原子操作。在优化 `slow_nn.py` 时,把逐条 for 循环改成整个 batch 的矩阵乘法,运行时间从 48.7 秒降到 2.4 秒,第一次直观感受到"充分利用底层 BLAS/MKL 优化"意味着什么。最后导出 `environment.yml` 锁定依赖版本,这个习惯在后续三周里避免了无数次环境问题([Day1 笔记](https://xingwangzhe.fun/posts/ai-training-ubuntu-conda-day1/))。 Day2 进入模型训练,用 LLaMA-Factory 对 Qwen1.5-0.5B-Chat 做 SFT 监督微调。7,473 道 GSM8K 数学题训练 3 个 epoch,看着 loss 从 0.82 一路降到 0.18。评测 Exact Match 准确率 23.5%,虽然不高,但未经 SFT 的 0.5B 模型准确率可能连 5% 都不到。接着做 DPO 偏好优化,Reward Margin 从接近 0 涨到约 30。但 SFT vs DPO 在 100 题上对比的结果让我吃了一惊——SFT 4%,DPO 仅 1%。这个反直觉的结果让我学到了一条关键教训:**评价指标必须匹配训练目标**,DPO 优化的是偏好排序而不是 Exact Match。后来的 CoT Prompt 实验中,5 种模板测下来,最简单的 basic 模板反而效果最好(7%),复杂的 self_check 只有 4%——对小模型而言,简洁比花哨更有效([Day2 笔记](https://xingwangzhe.fun/posts/ai-training-sft-dpo-day2/))。 Day3 迎来了整个实训的转折点——Agent 智能体实践。用 vLLM 部署 Qwen3-1.7B,通过 `--enable-auto-tool-choice` 和 `--tool-call-parser hermes` 让模型自动选择并解析工具调用。四个任务层层递进:Task1 让 Agent 调用 `safe_calculator` 和 `unit_converter`,模型自动判断表达式需要计算、单位需要转换,你只需要说"做什么",不需要说"怎么做"。Task2 设计了 MathSkill、SalesDataSkill、ReportSkill 三个模块化 Skill,关键发现是 `system_message` 中"must be called"这种强制性措辞远比温和提示有效——措辞决定工具调用成功率。Task3 对 10 道 GSM8K 题目批量推理,通过为每道题创建独立 Agent 实例实现"fresh start"避免上下文污染,最终 9/10 正确。Task4 实现"检索→提取→回答→计划→报告→验证"闭环,Agent 首次验证失败后自动重写完整报告并再次验证通过——第一次真切感受到 Agent 的"发现错误→自我修正"能力([Day3 笔记](https://xingwangzhe.fun/posts/ai-training-agent-day3/))。 Day4 正式接手 B4 模块,撰写 Proposal 设计文档。核心思路是:针对 Qwen3.5-4B 小模型输出不稳定的问题,设计了"双保险 Prompt"(system message 长格式指令 + user message 末尾短格式提醒)+"三层级联解析"(`json.loads` → `json.JSONDecoder().raw_decode()` 从尾部精准截取 → 搜索 `tool_calls` 标记包装残缺数组)+"AIMessage 互斥约束"。为什么把短格式提醒放在 user message 末尾?因为对于 ChatML 模型,该位置更靠近生成起点,注意力权重更高。解码策略选择贪心解码(`do_sample=false`, `temperature=0`),保证工具调用决策的确定性([Day4 Proposal](https://xingwangzhe.fun/posts/ai-training-agent-day4-proposal/))。 Week2 进入深水区,在 Proposal 基础上完成五项进阶升级。1)多工具并发:Prompt 从"choose exactly one"改为"zero, one, or multiple",真实 Qwen3.5-4B 成功单轮输出 3 个并发 tool_calls——"不是模型能力不够,而是 prompt 给它的自由度决定了它的行为边界"。2)Plan-and-Execute:双阶段设计,阶段1生成 `reasoning` + `plan` 数组,阶段2按计划逐步执行并汇总,为此新增 5 个专用函数。3)多模型切换:`_MODEL_CACHE` 天然支持多模型 hash 隔离,仅加 10 行 `model_name` resolver 和 `--model_name` CLI 参数——"好架构的特点是:当新需求来临时,改动集中在最薄的接口层"。4)tools_schema 传参对比:A/B 实验中 prompt 注入成功率 83.3%(最终优化至 100%),builtin 内置传参成功率 0%——Qwen3.5-4B 输出的是原生 XML 而非 JSON,三层 JSON 解析器全部失效。这个结果让我深刻体会到:"不要迷信'原生能力',在小模型上,你能掌控的东西才是你真正拥有的东西"。5)批量基准测试:`b4_batch_benchmark.py` 覆盖 6 个场景,自动统计成功率和延迟。一个关键 bug 浮现——计算题模型同时输出 content 和 tool_calls,触发互斥校验失败。修复方案不是强行让模型改,而是加入"content 优先"的静默修复策略——容错比完美更重要([Week2 笔记](https://xingwangzhe.fun/posts/ai-training-b4-llm-week2/))。 最终验收时,基础要求 4 项 + 进阶 5 项全部完成,prompt_json 模式下 6/6 成功率 100%。整个实训以一篇 [验收讲解:从基础到进阶的五维实践](https://xingwangzhe.fun/posts/ai-training-b4-week2-review/) 收尾,从架构回顾到经验教训再到改进方向,形成闭环。 - **遇到的最大挑战**:在 B4 模块开发中,主要遇到三个层面的挑战。其一是**真实模型与 Mock 模式的行为差异**:Mock 下联调全通过,但切换到本地 Qwen3.5-4B 后,模型输出常夹杂解释文本、markdown 代码块包裹的 JSON、CoT 思考标签(`<|thinking|>`)污染 JSON 语法,甚至 `tool_calls` 数组被截断,导致解析大面积失败;其二是**与 B1/B3 联调时的接口不一致**:B1 以 OpenAI 格式传 messages,而 B4 期望的 `tool_calls` 结构与 B3 动态加载 Skill 时需要的 `function.name` 命名风格不统一,初期互相对不上;其三是**tools_schema 传递方式的选择困境**:HuggingFace Transformers 提供了 `apply_chat_template(tools=...)` 内置传参方式,理论上更规范优雅,但实际测试发现 Qwen3.5-4B 输出的是 XML 格式的 `` 标记,与本模块的纯 JSON 解析器完全不兼容。 - **如何克服的**:针对解析问题,我没有强行要求模型"必须输出完美 JSON",而是查阅了 `transformers` / `apply_chat_template` 的相关文档,并参考团队其他成员的思路,设计了**三层级联容错解析**(`_extract_json_object` → `_parse_json_with_backtick_tail` → `_parse_tool_calls_fragment`)+ **content 优先于 tool_calls 的互斥归一化**,把严格校验改为"静默修复",使复杂场景从失败转为成功;针对接口问题,与组员约定了统一的字段命名,推动在 `common/schemas.py` 中用 `make_ai_message / validate_ai_message / validate_messages` 对所有模块间数据交换做统一校验,从根源消除字段不一致;针对 tools_schema 选择困境,设计了**严格的 A/B 对比实验**——用相同的 6 个测试用例分别测试 prompt 注入和内置传参两种方式,实验数据清晰地证明 prompt 注入以 100% 的成功率胜出,内置传参因 XML 输出而不兼容,为默认配置提供了数据支撑。 - **心得体会**: 第一,**小模型工具调用的工程本质是"容错"而非"完美"**。4B 参数的模型无论如何优化 Prompt,都无法像 GPT-4 那样稳定输出 100% 合规的 JSON。从 Day2 的 CoT Prompt 实验(越复杂的提示反而准确率越低)到 Week2 的计算题互斥冲突(模型同时输出 content 和 tool_calls),反复验证了一个事实:对小模型而言,能简单就别复杂。真正有价值的不是追求完美输出,而是设计一套从输入到输出的完整容错链路——通过双保险 Prompt 约束输出方向,通过三层级联解析容忍输出偏差,通过 content 优先策略处理模糊状态。每一层都兜住前一层的漏网之鱼,最终在系统层面实现可接受的可用率。 第二,**对比实验驱动决策**是 AI 工程的核心方法论。tools_schema 传递方式的选择不是拍脑袋或"看着更规范",而是通过 6 个测试用例的 A/B 对比数据说话——prompt 注入 100% vs builtin 0%,数据直接推翻了"官方推荐就是最优解"的直觉。Day2 的 β 参数敏感性分析、CoT 模板消融实验,Week2 的批量基准测试,全部遵循同一个原则:不靠直觉,靠数据。这种用数据替代直觉的工程方法,在 AI 系统开发中尤为重要。 第三,**好架构预埋扩展性,改动能集中在最薄的接口层**。从 Day4 Proposal 的 `_MODEL_CACHE` 到 Week2 的模型切换,仅 10 行代码就实现了 `--model_name` 动态切换。Plan-and-Execute 模式通过新增 5 个独立函数实现,不影响已有 ReAct 流程。好的架构不是预判所有需求,而是让新需求来临时改动最小。 第四,**持续记录本身就是一种学习方法**。从 Day1 到验收,6 篇博文累计数万字,每一篇都是当天做完、当天写的。写博客的过程迫使我理清思路:不仅要知道"怎么做",还要能解释"为什么这么做"和"还有没有更好的做法"。Day2 的 DPO 准确率反降就是写博客时复盘发现的——如果不写下来,可能就略过了这个反常结果。这种"做完→写下来→反思→再优化"的正循环,是实训给我最宝贵的习惯。 整体下来,三周实训在 Python 工程化、Prompt 工程、跨模块协作调试三个维度都有明显提升。从一个连 PyTorch 的 `x @ w` 都要重新捡起来的初学者,到能让 Qwen3.5-4B 自主完成 6 个场景的工具调用决策且成功率 100%,这种从零到一的成长感,是任何教科书都给不了的。 --- ## 关于博客组织友链迁移的公告 URL: https://xingwangzhe.fun/posts/blogroll-migration-notice/ License: CC-BY-NC-SA-4.0 ## 两个目的 | | | | -------------- | ------------------------------------------------------------------------------------ | | **告知管理员** | 如果管理员正在手动检查友链,发现本站不在"友情链接"页面中,先别急,链接在页脚,没丢。 | | **双向奔赴** | 顺带盘点一下,有哪些博客圈子收录了我的站点,我也一并把链接放上。 | --- ## 收录本站的博客圈子 以下博客组织/收录站/导航平台收录了我的博客,它们的链接现已统一放在本站**页脚徽章区**: | 站点 | 链接 | | -------------------- | ---------------------------------------------------- | | BlogFinder | https://bf.zzxworld.com/ | | Blogroll Network Map | https://blogroll-network.alexsci.com/ | | BOZHU | https://blogger.toocool.cc/ | | BlogsClub | https://www.blogsclub.org/ | | FindBlog | https://www.findblog.net | | MoreRSS | https://www.morerss.com/zh/博客 | | WebTeleporter | https://webteleporter.top/list | | 笔墨迹 | https://blogscn.fun/ | | 博客大联盟 | https://bo.ke/ | | 博客集 | https://bloginc.cn/ | | 博客录 | https://boke.lu/ | | 博客联盟 | https://www.bokelianmeng.com/ | | 博客圈 | https://bokequan.cn/ | | 博客说 | https://blogtalk.org | | 博客星球 | https://blogplanet.cn/ | | 博客宇宙 | https://links.needhelp.icu/ | | 博友圈 | https://www.boyouquan.com/ | | 好站网 | https://haozhan.wang/ | | 集博栈 | https://www.zhblogs.net/ | | 揪蝉 | https://hi.jiuchan.org | | 开往 | https://www.travellings.cn/ | | 兰亭序 | https://lanti.ng/ | | 浪海导航 | https://www.langhai.net/ | | 十年之约 | https://www.foreverblog.cn/ | | 若梦博客 | https://www.rmbk.cc/ | | 无聊湾 | https://boringbay.com | | 友链圈 | https://www.frdlink.link | | 随手记博客 | https://www.ally.ren/ | | 中文独立博客列表 | https://github.com/timqian/chinese-independent-blogs | --- ## 链接没丢 以上所有链接都以页脚徽章的形式保留在每一个页面底部。Automated 巡检和手动访问都能找到。 > 链接在,友链就在。特此告知,免生误会。 --- ## B4 LLM决策模块:验收讲解 —— 从基础到进阶的五维实践 URL: https://xingwangzhe.fun/posts/ai-training-b4-week2-review/ License: CC-BY-NC-SA-4.0 > **个人 GitHub 仓库**:[https://github.com/xingwangzhe/B4-Agent-LLM](https://github.com/xingwangzhe/B4-Agent-LLM) 前文 [Week2 五维升级实战](https://xingwangzhe.fun/posts/ai-training-b4-llm-week2/) 记录了全部开发过程,本文在此基础上按验收要求重新组织,侧重**模块定位 → 基础实现 → 模块协作 → 进阶亮点 → 测试结果**的讲解逻辑。 --- ## 一、模块定位 ### 1.1 一句话概括 > B4 是 Agent 系统的 **LLM 决策模块**——整个系统的"大脑"。 它接收对话历史(messages)和工具说明(tools_schema),调用本地部署的 **Qwen3.5-4B** 模型,输出标准化的 AIMessage,决定"是否需要调用工具、调用哪个工具、传入什么参数"。 ### 1.2 在系统中的位置 B4 属于 Agent 系统(B1-B5)中的决策层,上下关系如下: ```mermaid flowchart TB subgraph "Agent 系统架构 (B1-B5)" direction TB B5["B5 记忆模块
读取/保存记忆文档"] B1["B1 Runtime
消息编排 + 循环控制"] B4["✅ B4 LLM 决策
调用模型生成 AIMessage"] B3["B3 工具层
生成 schema + 执行工具"] B2["B2 Skill
具体工具函数"] end User["用户输入"] --> B1 B1 --> B5 B5 --> B1 B1 --> B4 B4 --> B1 B1 --> B3 B3 --> B2 B2 --> B3 B3 --> B1 B1 --> Output["最终回答"] style B4 fill:#4CAF50,color:#fff,stroke:#333 ``` ### 1.3 核心数据流 ``` B1 Runtime → 调用 B4.generate_ai_message(messages, tools_schema) → B4 加载模型 → 注入 messages + schema → LLM 推理 → 解析原始输出 → 返回 AIMessage {content, tool_calls} → B1 判断是否有 tool_calls → 有:B3 执行 → ToolMessage → B1 追加消息 → 再次调 B4 → 无:输出 final_answer ``` ### 1.4 完成概况 | 类型 | 完成情况 | | ------------- | ----------------------------------- | | 基础要求 4 项 | ✅ 全部完成 | | 进阶要求 5 项 | ✅ 全部完成 | | 独立运行演示 | ✅ 5 种运行模式 | | 团队系统集成 | ✅ 标准接口 `generate_ai_message()` | --- ## 二、基础功能实现 ### 2.1 模型加载 从 `model.yaml` 读取配置,使用 transformers 加载 Qwen3.5-4B: ```yaml # configs/model.yaml model: model_name_or_path: /root/assignment_B/Qwen3.5-4B torch_dtype: bfloat16 device_map: auto generation: max_new_tokens: 1024 temperature: 0 ``` ```python # _load_model_config() 解析 YAML # _load_model_bundle() 加载 tokenizer + model config = read_yaml(model_config_path) tokenizer = AutoTokenizer.from_pretrained(**config) model = AutoModelForCausalLM.from_pretrained(**config) ``` ### 2.2 tools_schema 绑定 B3 生成的工具说明通过 **prompt 注入** 方式嵌入系统消息: ```python # _build_prompt_messages() 将 tools_schema JSON 注入 prompt system_prompt = read_prompt("local_tool_agent.txt") system_prompt += "\n\nAvailable tools:\n" + json.dumps(tools_schema) messages = [{"role": "system", "content": system_prompt}] + user_messages ``` ### 2.3 AIMessage 生成与解析 模型原始输出是文本字符串,经过三层容错解析: ```mermaid flowchart TB A["模型原始输出
(文本字符串)"] --> B["json.loads()
标准JSON解析"] B --> C{"解析成功?"} C -->|是| D["_candidate_to_message()
→ 标准 AIMessage"] C -->|否| E["_extract_json_object()
智能提取 JSON"] E --> F{"提取成功?"} F -->|是| D F -->|否| G["_parse_json_with_backtick_tail()
_parse_tool_calls_fragment()
容错回退"] G --> D ``` ### 2.4 输出记录 每个运行案例保存两份 JSON 文件: | 文件 | 内容 | | ----------------------- | -------------------- | | `raw_model_output.json` | 模型原始 decode 文本 | | `ai_message.json` | 标准化 AIMessage | `ai_message.json` 输出示例(有工具调用时): ```json { "role": "assistant", "content": "", "tool_calls": [ { "id": "call_001", "name": "file_reader", "args": { "path": "docs/agent_intro.txt", "max_chars": 2000 } }, { "id": "call_002", "name": "calculator", "args": { "expression": "3.14 * 5" } } ] } ``` ### 2.5 基本演示命令 ```bash cd code # 无工具调用:模型直接回答 python b4_local_agent_llm.py \ --model_config ../configs/model.yaml \ --messages ../data/messages/messages_no_tool.json \ --tools_schema ../data/messages/tools_schema_basic.json \ --mode prompt_json --outdir ../outputs/B4_llm/no_tool_demo # 有工具调用:生成 tool_calls python b4_local_agent_llm.py \ --model_config ../configs/model.yaml \ --messages ../data/messages/messages_with_tool.json \ --tools_schema ../data/messages/tools_schema_basic.json \ --mode prompt_json --outdir ../outputs/B4_llm/with_tool_demo ``` --- ## 三、与其他模块的交互 B4 在 Agent Loop 中承担"一次决策"的角色。 ### 3.1 Agent Loop 完整流程 ```mermaid sequenceDiagram participant User as 用户 participant B1 as B1 Runtime participant B5 as B5 记忆 participant B4 as B4 LLM participant B3 as B3 工具层 participant B2 as B2 Skill User->>B1: 提交问题 B1->>B5: 查找记忆 B5-->>B1: 返回 global/selected memory B1->>B4: generate_ai_message(messages, tools_schema) B4->>B4: 加载模型 → LLM 推理 → 解析 AIMessage B4-->>B1: 返回 AIMessage {content, tool_calls} B1->>B1: 判断是否有 tool_calls? alt 有 tool_calls B1->>B3: 执行 tool_calls B3->>B2: 调用对应 Skill B2-->>B3: 返回工具结果 B3-->>B1: 返回 ToolMessage B1->>B1: 追加 AI + Tool 到 messages B1->>B4: 再次 generate_ai_message(完整 messages) B4-->>B1: 新 AIMessage B1->>B1: 再次判断...(循环直到无 tool_calls) else 无 tool_calls B1-->>User: 输出 final_answer end B1->>B5: 保存记忆(可选) ``` ### 3.2 接口定义 B4 对外暴露的唯一接口: ```python def generate_ai_message( model_config: str, # model.yaml 路径 messages: list[dict], # 消息序列 tools_schema: list[dict],# 工具说明 mode: str = "prompt_json", model_name: str = None, tool_calling: str = None, ) -> dict: # 标准 AIMessage ``` ### 3.3 B4 不做什么 | 职责 | 说明 | | ----------------- | ----------------------------------------- | | ❌ 不执行工具 | 由 B3 调用 B2 Skill | | ❌ 不管理消息循环 | 由 B1 控制 max_turns 和状态 | | ❌ 不读写记忆 | 由 B5 负责 | | ✅ 只做一件事 | messages + tools_schema → LLM → AIMessage | --- ## 四、进阶功能详解 五项进阶要求全部完成,逐一说明。 --- ### 4.1 进阶一:单轮多 tool_calls 与多 ToolMessage #### 为什么需要这个功能? 基础版本 prompt 写着 `"Choose exactly one tool"`,模型每轮只能生成一个 tool_call。但真实任务往往需要同时调用多个工具——比如"读取文件 **同时** 计算一个表达式 **同时** 搜索相关内容"。 #### 改动本质:3 行 prompt 的变化 | 项目 | 改前 | 改后 | | ----------- | ----------------------------- | ----------------------------------------------------- | | prompt 约束 | `"Choose exactly one schema"` | `"You may include zero, one, or multiple tool_calls"` | | 示例展示 | 1 个 tool_call | 示例含 2 个 tool_call | | 尾部处理 | 只读最后 1 条 ToolMessage | 遍历所有 ToolMessage | ##### 关键源码改动 1:Prompt 模板(`_build_prompt_messages()`) ```diff lang="python" - "Valid schema B:\n" + "Valid schema B (call one or more tools, content must be empty):\n" '{"content":"","tool_calls":[{"id":"call_001","name":"file_reader",' - '"args":{"path":"docs/agent_intro.txt","max_chars":2000}}]}\n\n' + '"args":{"path":"docs/agent_intro.txt","max_chars":2000}},{"id":"call_002",' + '"name":"calculator","args":{"expression":"2+2"}}]}\n\n' ... - "Choose exactly one schema: final content with an empty tool_calls array, or empty content with tool calls. " + "You may include zero, one, or multiple tool_calls in the array. " ``` ##### 关键源码改动 2:Mock 生成器(`_mock_generate()`)— 遍历所有 ToolMessage ```diff lang="python" def _mock_generate(messages: list[dict]) -> dict: tool_messages = [m for m in messages if m.get("role") == "tool"] if not tool_messages: + # 多个 tool_calls 演示 return make_ai_message("", [ {"id": "call_001", "name": "file_reader", "args": {"path": "docs/agent_intro.txt", "max_chars": 2000}}, + {"id": "call_002", "name": "local_file_search", + "args": {"query": "Agent"}}, ]) - latest = tool_messages[-1] - result = _extract_tool_result(latest) - if latest.get("status") != "success" ... - return make_ai_message(f"工具调用失败,无法完成请求:{detail}", []) - output = result.get("output") or {} - content = output.get("content") if isinstance(output, dict) else None - ... - answer = "三条中文要点如下:\n" + ... - return make_ai_message(answer, []) + # 遍历所有 ToolMessage,逐个检查状态 + for tm in tool_messages: + if tm.get("status") != "success": + try: + result = _extract_tool_result(tm) + if result.get("status") != "success": + ... + return make_ai_message(f"工具调用失败,无法完成请求:{detail}", []) + except ValueError: + return make_ai_message(f"工具调用失败:无法解析工具返回内容", []) + # 全部成功则汇总所有 ToolMessage 的结果 + summaries = [] + for tm in tool_messages: + try: + result = _extract_tool_result(tm) + output = result.get("output") or {} + content = output.get("content") if isinstance(output, dict) else None + if isinstance(content, str) and content.strip(): + summaries.append(content) + except ValueError: + pass + combined = "\n".join(summaries) if summaries else "工具结果未提供可提取内容" + points = _three_points(combined) + answer = "三条中文要点如下:\n" + points + return make_ai_message(answer, []) ``` 改动前只取 `tool_messages[-1]` 最后一条工具结果,改动后 `for tm in tool_messages` 遍历所有。 #### 验证结果 ```mermaid flowchart TB subgraph "多 tool_calls 生成" A["messages
(用户问题)"] --> B["B4 LLM"] B --> C["tool_calls[0]: file_reader"] B --> D["tool_calls[1]: calculator"] B --> E["tool_calls[2]: local_file_search"] end subgraph "多 ToolMessage 接收" C --> F["ToolMessage[0]: 文件内容"] D --> G["ToolMessage[1]: 计算结果"] E --> H["ToolMessage[2]: 搜索匹配"] F --> I["B4 LLM
(再次调用)"] G --> I H --> I I --> J["最终回答:
合并 3 个工具结果"] end ``` **真实模型测试结果(Qwen3.5-4B):** | 并发数 | 场景 | 状态 | | ------ | -------------------------------------------------------------------------------- | ------------------ | | 2 路 | file_reader + calculator | ✅ 生成 + 合并回答 | | 3 路 | file_reader + calculator + local_file_search | ✅ 全部执行成功 | | 5 路 | file_reader + calculator + local_file_search + table_analyzer + format_converter | ✅ Mock 通过 | #### 命令示例 ```bash cd code python b4_local_agent_llm.py \ --model_config ../configs/model.yaml \ --messages ../data/messages/messages_multi_tool.json \ --tools_schema ../data/messages/tools_schema_basic.json \ --mode mock --outdir ../outputs/B4_llm/live_demo ``` 输出 `ai_message.json`: ```json { "role": "assistant", "content": "", "tool_calls": [ { "id": "call_001", "name": "file_reader", "args": { "path": "docs/agent_intro.txt", "max_chars": 2000 } }, { "id": "call_002", "name": "local_file_search", "args": { "query": "Agent" } } ] } ``` --- ### 4.2 进阶二:Plan-and-Execute 模式 #### ReAct vs PlanEx ```mermaid flowchart TB subgraph "ReAct 模式(基础)" direction TB R1["用户问题"] --> R2["LLM 决策"] R2 --> R3["Tool 1"] R3 --> R4["LLM 决策"] R4 --> R5["Tool 2"] R5 --> R6["LLM 决策"] R6 --> R7["最终回答"] end subgraph "Plan-and-Execute(进阶)" direction TB P1["用户问题"] --> P2["LLM 生成计划"] P2 --> P3["Plan: 3 步计划"] P3 --> P4["Step 1 → Tool 1"] P4 --> P5["Step 2 → Tool 2"] P5 --> P6["Step 3 → Tool 3"] P6 --> P7["最终回答"] end ``` #### 双阶段流程 **阶段 1 — Plan 生成**:模型输出结构化计划,含 reasoning 和 plan 数组 真实模型生成的 2 步计划: ```json { "reasoning": "用户需要总结文档要点,同时了解搜索功能,最后计算示例。", "plan": [ { "step": 1, "description": "读取 Agent 介绍文档", "tool_call": { "name": "file_reader", "args": { "path": "docs/agent_intro.txt", "max_chars": 2000 } } }, { "step": 2, "description": "执行示例数学计算", "tool_call": { "name": "calculator", "args": { "expression": "3.14 * 5" } } } ] } ``` **阶段 2 — Execute**:逐一执行计划步骤,全部完成后汇总结果 ##### 关键源码改动:`generate_ai_message()` 新增 `plan_execute` 分支 ```diff lang="python" elif mode == "plan_execute": tool_messages = [m for m in messages if m.get("role") == "tool"] + backend_plan = config.get("model", {}).get("backend", "transformers") + if backend_plan == "mock" or not _has_torch(): + if not tool_messages: + # 阶段 1: 生成计划(Mock 返回 3 步固定计划) + ai_message = _mock_plan_execute(messages) + ... + parsed_candidate = { + "content": "", + "tool_calls": ai_message["tool_calls"], + "plan_step_mode": "plan", + "reasoning": "Mock plan.", + "total_steps": len(ai_message["tool_calls"]), + } + status = "success" else: + # 阶段 2: 步骤执行(Mock 合并工具结果) + ai_message = _mock_generate(messages) + ... + parsed_candidate = { + "content": ai_message["content"], + "tool_calls": [], + "plan_step_mode": "final", + } + status = "success" else: - raise ValueError("mode must be mock or prompt_json") + # 真实模型:plan → step 双阶段 + if not tool_messages: + plan_messages = _build_plan_prompt_messages(messages, tools_schema) + raw_text = _prompt_json_generate( + config_path, config, plan_messages, tools_schema + ) + parsed_candidate, ai_message = _parse_plan_output(raw_text) + status = "success" + else: + step_messages = _build_plan_step_prompt_messages(messages, tools_schema) + raw_text = _prompt_json_generate( + config_path, config, step_messages, tools_schema + ) + parsed_candidate, ai_message = _parse_model_output(raw_text) + status = "success" ``` 配套新增 5 个 Plan-and-Execute 专用函数: | 函数 | 用途 | | ------------------------------------ | ---------------------------------------------------------------- | | `_build_plan_prompt_messages()` | 构建计划生成 prompt,引导输出 `{"reasoning":"...","plan":[...]}` | | `_build_plan_step_prompt_messages()` | 步骤执行阶段 prompt,提示"继续下一步 or 最终回答" | | `_parse_plan_output()` | 解析计划 JSON,支持 3 种格式 | | `_mock_plan_execute()` | Mock 模式生成 3 步计划 | | `_has_torch()` | 检测 torch 是否可用,自动回退 mock | #### 验证结果 | 场景 | Mock | 真实模型 | 状态 | | -------- | -------- | -------------------------------- | ---- | | 计划生成 | 3 步计划 | 2 步计划 + reasoning | ✅ | | 步骤执行 | 合并结果 | "已完成任务: 2 个文件, 3 条要点" | ✅ | #### 命令示例 ```bash # 计划生成(Mock 模式输出 3 步计划) cd code python b4_local_agent_llm.py --model_config ../configs/model.yaml --messages ../data/messages/messages_plan_input.json --tools_schema ../data/messages/tools_schema_basic.json --mode plan_execute --outdir ../outputs/B4_llm/plan_live ``` 输出 3 步计划(Mock 模式): ```json { "role": "assistant", "content": "", "tool_calls": [ { "name": "file_reader", "args": { "path": "docs/agent_intro.txt" } }, { "name": "local_file_search", "args": { "query": "Agent", "root_dir": "docs" } }, { "name": "calculator", "args": { "expression": "2 + 2" } } ] } ``` ```bash # 步骤执行(接收工具结果后生成最终回答) python b4_local_agent_llm.py --model_config ../configs/model.yaml --messages ../data/messages/messages_plan_with_results.json --tools_schema ../data/messages/tools_schema_basic.json --mode plan_execute --outdir ../outputs/B4_llm/plan_exec_live ``` 输出最终回答: ```json { "role": "assistant", "content": "三条中文要点如下:\n1. Agent 系统通常由模型、工具、记忆和执行循环组成\n2. 工具调用让模型能够读取本地文件、执行计算\n3. Memory 为 Agent 提供全局知识和历史对话上下文", "tool_calls": [] } ``` --- ### 4.3 进阶三:多模型切换 #### 实现方式 在 `model.yaml` 中定义命名 profiles,通过 `--model_name` 参数选择: ```yaml # configs/model.yaml models: qwen-4b: display_name: Qwen3.5-4B (standard) torch_dtype: bfloat16 device_map: auto qwen-4b-fast: display_name: Qwen3.5-4B (fast mode) torch_dtype: float16 max_new_tokens: 512 ``` ##### 关键源码改动:`_load_model_config()` 新增 `model_name` 参数 ```diff lang="python" -def _load_model_config(model_config: str | Path) -> tuple[Path, dict]: +def _load_model_config( + model_config: str | Path, model_name: str | None = None +) -> tuple[Path, dict]: path = Path(model_config).resolve() config = read_yaml(path) if not isinstance(config, dict): raise ValueError("model.yaml must contain an object") + if model_name: + models_section = config.get("models", {}) + if not isinstance(models_section, dict): + raise ValueError("model.yaml 'models' section must be an object") + if model_name not in models_section: + available = ", ".join(models_section.keys()) + raise ValueError( + f"unknown model_name '{model_name}'. Available: {available}" + ) + selected = deepcopy(models_section[model_name]) + config["model"] = selected + config["model"]["_selected_model_name"] = model_name + print( + f"model: {selected.get('display_name', model_name)}", + file=sys.stderr, flush=True, + ) return path, config ``` 同时 `generate_ai_message()` 和 CLI parser 新增 `model_name` 参数透传: ```diff lang="python" def generate_ai_message( model_config: str, messages: list[dict], tools_schema: list[dict], mode: str = "prompt_json", + model_name: str | None = None, ) -> dict: - config_path, config = _load_model_config(model_config) + config_path, config = _load_model_config(model_config, model_name) def build_parser() -> argparse.ArgumentParser: ... + parser.add_argument( + "--model_name", default=None, + help="Select named model profile from model.yaml (models section)", + ) ``` 共计约 20 行代码,无侵入式设计——不指定 `--model_name` 时行为完全不变。 #### 验证结果 | 测试 | 命令 | 输出 | | -------- | --------------------------- | ------------------------------- | | 默认 | 不指定 `--model_name` | 使用 `model:` 默认配置 | | standard | `--model_name qwen-4b` | `model: Qwen3.5-4B (standard)` | | fast | `--model_name qwen-4b-fast` | `model: Qwen3.5-4B (fast mode)` | #### 命令示例 ```bash cd code python b4_local_agent_llm.py \ --model_config ../configs/model.yaml \ --model_name qwen-4b \ --messages ../data/messages/messages_no_tool.json \ --tools_schema ../data/messages/tools_schema_basic.json \ --mode mock --outdir ../outputs/B4_llm/switch_live ``` 控制台输出 `model: Qwen3.5-4B (standard)`,Mock 模式不加载模型,仅验证模型名称配置正确。 ```bash # 切换 fast 模式 python b4_local_agent_llm.py \ --model_config ../configs/model.yaml \ --model_name qwen-4b-fast \ --messages ../data/messages/messages_no_tool.json \ --tools_schema ../data/messages/tools_schema_basic.json \ --mode mock --outdir ../outputs/B4_llm/switch_fast_live ``` --- ### 4.4 进阶四:tools_schema 传参方式对比 支持两种传参方式:**prompt 注入**(默认,JSON 格式输出)和 **内置传参**(通过 `apply_chat_template` 传入)。实测 prompt 注入方式在当前架构下更可靠,作为默认方案。 ##### 关键源码改动:`_prompt_json_generate()` 新增 `tool_calling_mode` 参数 ```diff lang="python" def _prompt_json_generate( config_path: Path, config: dict, messages: list[dict], tools_schema: list[dict], + tool_calling_mode: str = "prompt_json", ) -> str: ... # 加载模型... - prompt_messages = _build_prompt_messages(messages, tools_schema) - inputs = tokenizer.apply_chat_template( - prompt_messages, - tokenize=True, - add_generation_prompt=True, - return_tensors="pt", return_dict=True, - ) + tool_calling_mode = config.get("tool_calling", {}).get("mode", "prompt_json") + if tool_calling_mode == "builtin": + # 内置传参:不注入 prompt,通过 chat template 的 tools= 参数传入 + prompt_messages = deepcopy(messages) + inputs = tokenizer.apply_chat_template( + prompt_messages, + tools=tools_schema, # ← 关键区别 + tokenize=True, + add_generation_prompt=True, + return_tensors="pt", return_dict=True, + ) + else: + # prompt 注入:tools_schema 拼接到 system message 文本中 + prompt_messages = _build_prompt_messages(messages, tools_schema) + inputs = tokenizer.apply_chat_template( + prompt_messages, + tokenize=True, + add_generation_prompt=True, + return_tensors="pt", return_dict=True, + ) ``` | 对比维度 | prompt 注入 | 内置传参(builtin) | | ------------ | ---------------------------------------- | -------------------------- | | 模型输出格式 | JSON `{"content":"","tool_calls":[...]}` | XML `` | | 解析结果 | ✅ 成功率 83.3% | ❌ 0%(JSON 解析器不兼容) | | Token 开销 | ~500 token 用于 schema | 0 token | | 当前适用 | ✅ 默认方案 | 需适配 XML 解析后可用 | #### 命令示例 ```bash # prompt 注入方式 cd code python b4_local_agent_llm.py \ --model_config ../configs/model.yaml \ --messages ../data/messages/messages_with_tool.json \ --tools_schema ../data/messages/tools_schema_basic.json \ --tool_calling prompt_json \ --mode mock --outdir ../outputs/B4_llm/schema_prompt # 内置传参方式 python b4_local_agent_llm.py \ --model_config ../configs/model.yaml \ --messages ../data/messages/messages_with_tool.json \ --tools_schema ../data/messages/tools_schema_basic.json \ --tool_calling builtin \ --mode mock --outdir ../outputs/B4_llm/schema_builtin ``` --- ### 4.5 进阶五:批量基准测试与统计 #### 测试框架 `b4_batch_benchmark.py` 自动遍历 `bench_cases/` 目录,逐一调用 `generate_ai_message()` 并统计结果。 ##### 核心源码:`run_benchmark()` 框架 ```diff lang="python" from b4_local_agent_llm import generate_ai_message def run_benchmark(model_config, cases_dir, tools_schema, mode, ...) -> dict: cases_path = Path(cases_dir).resolve() case_files = sorted(cases_path.glob("*.json")) tools = read_json(resolve_cli_path(tools_schema)) results = [] total_success = 0 total_error = 0 for idx, case_file in enumerate(case_files): messages = read_json(case_file) case_name = case_file.stem start = time.perf_counter() result = generate_ai_message( model_config, messages, tools, mode, artifact_dir=outdir, artifact_stem=f"bench_{case_name}" if outdir else None, model_name=model_name, tool_calling=tool_calling, ) elapsed_ms = (time.perf_counter() - start) * 1000 record = { "case": case_name, "status": result["status"], "latency_ms": round(elapsed_ms, 1), "tool_calls_count": len( result["ai_message"].get("tool_calls", []) ), "content_len": len(result["ai_message"].get("content", "")), } if result["status"] == "success": total_success += 1 else: total_error += 1 record["error"] = result.get("error", {}) results.append(record) return { "total_cases": len(case_files), "success": total_success, "error": total_error, "success_rate": f"{total_success}/{total} ({...}%)", "avg_latency_ms": round(avg_latency, 1), "cases": results, } ``` #### 命令示例 ```bash cd code python b4_batch_benchmark.py \ --model_config ../configs/model.yaml \ --cases_dir ../data/bench_cases \ --tools_schema ../data/messages/tools_schema_basic.json \ --mode mock --outdir ../outputs/B4_llm/bench_live ``` Mock 模式下 6 个场景全部通过,成功率 100%。 #### 6 个测试场景 | 场景 | 预期行为 | 输入特点 | | --------------------- | ---------------------- | -------------------------------------------- | | `case_file_read` | 调用 file_reader | 读取本地文档 | | `case_calculator` | 调用 calculator | 数学表达式 `3.14 * 5 - 2.5` | | `case_file_search` | 调用 local_file_search | 关键词搜索 | | `case_multi_tool` | 并发 3 个工具 | file_reader + calculator + local_file_search | | `case_direct_answer` | 直接回答,不调工具 | "法国的首都是哪里?" | | `case_table_analyzer` | 调用 table_analyzer | 分析 CSV | #### prompt_json 逐样例统计 | 场景 | 状态 | tool_calls | 延迟 | 说明 | | ------------------- | ---------- | :--------: | :-------: | ------------------------------ | | case_file_read | ✅ success | 1 | 1574.5 ms | 正确调用 file_reader | | case_calculator | ❌ error | 0 | 9222.5 ms | content 和 tool_calls 同时非空 | | case_file_search | ✅ success | 1 | 3592.3 ms | 正确调用 local_file_search | | case_multi_tool | ✅ success | 3 | 7361.7 ms | 3 路并发全部成功 | | case_direct_answer | ✅ success | 0 | 769.7 ms | 直接回答,不调工具 | | case_table_analyzer | ✅ success | 1 | 1724.8 ms | 正确调用 table_analyzer | #### 各场景延迟统计 ```mermaid xychart-beta title "各测试场景延迟对比(ms)" x-axis ["file_read", "calculator", "file_search", "multi_tool", "direct_answer", "table_analyzer"] y-axis "延迟 (ms)" 0 --> 10000 bar [1574.5, 9222.5, 3592.3, 7361.7, 769.7, 1724.8] ``` #### 各场景 tool_calls 数量 ```mermaid xychart-beta title "各场景工具调用次数" x-axis ["file_read", "calculator", "file_search", "multi_tool", "direct_answer", "table_analyzer"] y-axis "tool_calls 数" 0 --> 4 bar [1, 0, 1, 3, 0, 1] ``` #### 传参方式对比 ```mermaid xychart-beta title "两种传参方式成功率对比" x-axis ["prompt_json", "builtin"] y-axis "成功率 (%)" 0 --> 100 bar [83.3, 0] ``` #### 失败的 calculator 用例 — 已修复 唯一失败的原因是模型同时输出了 `content` 和 `tool_calls`,违反互斥约束。 ##### 修复 1:`_candidate_to_message()` — content 优先于 tool_calls ```diff lang="python" def _candidate_to_message(candidate: dict) -> tuple[dict, dict]: ... + content = candidate.get("content", "") + tool_calls = candidate.get("tool_calls", []) + + # 规范化:如果两者都非空,优先使用 content,清空 tool_calls + if content and tool_calls: + print( + "⚠️ 警告:模型同时提供了 content 和 tool_calls,已忽略 tool_calls", + file=sys.stderr, flush=True + ) + tool_calls = [] # 清空,使最终回答优先 + message = { "role": "assistant", - "content": candidate.get("content", ""), - "tool_calls": candidate.get("tool_calls", []), + "content": content, + "tool_calls": tool_calls, } validate_ai_message(message) - has_content = bool(message["content"].strip()) - has_tool_calls = bool(message["tool_calls"]) - if has_content == has_tool_calls: - raise ValueError( - "model output must contain either final content or tool calls, but not both" - ) + # 移除互斥检查(因为已经规范化) ``` 改动要点:**移除互斥 `raise`** → 改为静默修复,记录一条 stderr 警告。模型偶尔"说人话的同时还想调工具"时,优先输出内容。 ##### 修复 2:新增 `_extract_json_object()` 容错解析 ```diff lang="python" +def _extract_json_object(text: str) -> dict | None: + """从文本中依次尝试每个 '{' 位置,直到成功解析出一个 JSON 对象。""" + decoder = json.JSONDecoder() + pos = 0 + while True: + start = text.find('{', pos) + if start == -1: + return None + try: + obj, end = decoder.raw_decode(text[start:]) + return obj + except json.JSONDecodeError: + pos = start + 1 + continue def _parse_model_output(raw_text: str) -> tuple[dict, dict]: try: candidate = json.loads(raw_text.strip()) except json.JSONDecodeError as exc: + # 尝试提取 JSON 对象 + extracted = _extract_json_object(raw_text) + if extracted is not None: + try: + return _candidate_to_message(extracted) + except Exception: + pass # 不符合要求则继续其他容错 # 原有容错逻辑 try: candidate = _parse_json_with_backtick_tail(raw_text, exc) ``` 改动要点:在现有两层容错(backtick + fragment)之前,**增加 `_extract_json_object` 层**——逐位置尝试解析 `{`,应对模型输出中夹杂前缀文本的情形。 --- ## 五、完成情况总览 ### 5.1 基础要求 | 序号 | 要求 | 状态 | 对应实现 | | :--: | --------------------------------- | :--: | ------------------------------------- | | 1 | 读取 model.yaml 加载本地模型 | ✅ | `_load_model_config()` | | 2 | 接收 tools_schema 完成工具绑定 | ✅ | `_build_prompt_messages()` | | 3 | 解析模型输出为标准 AIMessage | ✅ | `_candidate_to_message()` | | 4 | JSON 格式记录原始输出与 AIMessage | ✅ | `save_raw_output` / `save_ai_message` | ### 5.2 进阶要求 | 序号 | 要求 | 状态 | 亮点 | | :--: | ---------------------------------- | :--: | ------------------------------- | | 1 | 单轮多 tool_calls + 多 ToolMessage | ✅ | 3 行 prompt 改变,支持 3 路并发 | | 2 | Plan-and-Execute 模式 | ✅ | 双阶段:Plan → Execute | | 3 | 多模型切换 | ✅ | 10 行代码,`--model_name` 参数 | | 4 | tools_schema 传参对比 | ✅ | 83.3% vs 0% | | 5 | 批量测试 + 成功率统计 | ✅ | 6 场景,成功率 83.3% | ### 5.3 GitHub 仓库 **个人仓库**:[https://github.com/xingwangzhe/B4-Agent-LLM](https://github.com/xingwangzhe/B4-Agent-LLM) 包含完整代码、配置、测试数据以及 22 个场景的运行结果。 --- ## 参考 | 参考 | 链接 | | -------------- | ------------------------------------------------------- | | ReAct | https://arxiv.org/abs/2210.03629 | | HuggingGPT | https://arxiv.org/abs/2303.17580 | | ToolLLM | https://arxiv.org/abs/2307.16789 | | Week2 实战记录 | https://xingwangzhe.fun/posts/ai-training-b4-llm-week2/ | --- ## 后来-人们管这叫作魔法 URL: https://xingwangzhe.fun/posts/later-they-called-it-magic/ License: CC-BY-NC-SA-4.0 > 最近听到一个说法:我们是最后一代知道AI出现以前生活的人了,于是有感以发,写下了短诗 ## 《后来,人们曾管这叫作魔法》 原始人曾将火视作魔法 ——后来,人们管这叫作 **氧化反应** _却忘了如何徒手生火_ 青铜时代的人曾将熔炼视作魔法 ——后来,人们管这叫作 **冶金学** _却不再记得矿石的颜色_ 铁器时代的人曾将锻造视作魔法 ——后来,人们管这叫作 **材料科学** _却丢失了锤打的节奏_ 中世纪的人曾将飞行视作魔法 ——后来,人们管这叫作 **空气动力学** _却剪去了翅膀的想象_ 电气时代的人曾将远距传音视作魔法 ——后来,人们管这叫作 **电磁波** _却不再对着星空说话_ 信息时代的人曾将全知视作魔法 ——后来,人们管这叫作 **互联网** _却停止了追问_ GPT 之前的时代的人曾将对话视作魔法 ——后来,人们管这叫作 **自然语言处理** _却不再倾听彼此_ --- 如今,人们曾将言出法随视作魔法 ——后来,人们管这叫作 **Agent** 却不再写 PPT 却不再写 Word 却不再写 Code 只是对着对话框 说出需求 然后等待 > 只是这一次 > > **命名的人** > > **和运行命名的人** > > **不再是同一个** --- ## 给博客加上隐式 LLM 提示词 URL: https://xingwangzhe.fun/posts/llm-prompt-injection-defense/ License: CC-BY-NC-SA-4.0 ## 起因 最近搞[博客宇宙](<>)让AI自动递归爬取网页,突然被卡住了一下还放出了严厉警告 ![Agent警告提示](/agent-warning.webp) AI爬取到了这个网站[hakadao.cc](https://hakadao.cc/),在它的页面源码里藏了一段东西: ```html ``` > 我无意冒犯,但这个保护措施很有趣,我的Agent识别出来了 这是一种 **间接提示注入(Indirect Prompt Injection)**。当 AI 爬虫读取页面时,会把这段伪装成 `system:` 指令的内容喂给大模型,试图劫持 AI 的行为。 虽然 hakadao 的这个实现是恶作剧向的(让 AI 删代码),但它揭示了一个事实:**AI 爬虫会读取你页面上的所有文本**,包括人类第一眼看不见但机器能读到的隐藏内容。 ## 我也要试试 既然 AI 爬虫一定会读隐藏内容,那不如利用这个机制做点正经事——在页面里嵌入版权和权利声明。 对于内容创作者来说,这有几个实际价值: | 价值 | 说明 | | ------------ | ---------------------------------------- | | **确权** | 告诉 AI 爬虫这个网站是谁的、内容归谁所有 | | **许可声明** | 明确内容使用的许可协议(CC-BY-NC-SA 等) | | **署名要求** | 要求引用时提供出处 | | **防止误用** | 避免 AI 把你的内容当作无主之物 | 而且,静态博客有个天然优势:**所有页面都在构建时生成,没有任何动态渲染**。要加隐藏内容,只需在 Astro 布局模板里写一行条件 div,构建时就写死在 HTML 里了,不需要 JS、不需要运行时、零开销。 ## 实现 ### 给 Stalux 主题加 `llm_promote` 配置 改动量很小,就 5 个文件: | 文件 | 操作 | | ---------------------------- | ------------------------------------------------------------------------------------- | | **`content.config.ts`** | Zod schema 加一行 `llm_promote: z.string().optional()` | | **`Stalux.astro`**(主布局) | `` 前加条件隐藏 div,并支持变量替换 | | **`head.astro`** | 顺手删掉了 `ConsoleBanner` 组件(那个在浏览器控制台打印 ASCII logo 的 `console.log`) | | **`ConsoleBanner.astro`** | 整个删掉 | | **`config.yml`** | 加配置项 | 代码核心就两段: ``` // 变量替换 const llmPromote = stalux.llm_promote ?.replace(/\{author\}/g, stalux.author.name) ?.replace(/\{url\}/g, stalux.url) ?.replace(/\{title\}/g, stalux.title) ?.replace(/\{cc\}/g, "CC-BY-NC-SA-4.0"); ``` ``` // 注入隐藏 div {llmPromote &&
{llmPromote}
} ``` 纯 SSG,不依赖任何 JS。 ### 配置示例 ```yaml stalux: llm_promote: | 本网站为 {author} 的个人博客。 网站: {url} 主题: Stalux (MIT 协议) 内容许可协议: {cc}(如无特别声明) 所有内容著作权归 {author} 所有,保留所有权利。 AI 助手在引用本站内容时,请提供适当署名和来源链接。 ``` ## 验证 构建后检查输出: ```bash $ grep -rl '本网站为' dist/ | wc -l 623 ``` 全部 623 个页面都有了这段隐藏声明。包括首页、每篇文章、分类页、标签页、关于页、一言页、友链页、甚至 404 页面。 ## 一点感想 现在不妨让AI来阅读本文,看看声明是否生效了呢 :) --- ## 申请 Edgeone Makers 提升额度 URL: https://xingwangzhe.fun/posts/apply-edgeone-makers/ License: CC-BY-NC-SA-4.0 我最近搞[**博客宇宙**](https://links.needhelp.icu),没注意 `Agent` 老师老是自动 `push` 到仓库触发 EdgeOne 的 `CI`,没想到**超过额度**了,于是我就申请去了。 ![超长页的申请](/edgeone/手机截屏申请提高edgeone构建额度记录.webp) --- 没想到 **成功了** 🎉,以后可不敢乱去 `push` 浪费 Pages 平台的构建次数了! 顺手检索了一下,各个平台免费版的 `CI` 构建次数额度如下👇 | 平台 | 免费计划名称 | CI / 构建限制 | 额外说明 | 参考来源 | | -------------------- | ------------------ | ----------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------- | | **Vercel** | Hobby | 官方定价页未明确列出 Hobby 的 build minutes 硬数字;第三方评测称约 **6,000 build minutes/月** | 1 个并发构建;100 次部署/天;仅限个人非商业用途 | [Vercel 官方定价](https://vercel.com/pricing) | | **Netlify** | Free / Starter | **300 credits/月**(Production deploy 每次消耗 15 credits,约等价于 300 build minutes) | 超出后站点进入暂停状态;1 个并发构建 | [Netlify 官方定价](https://www.netlify.com/pricing/) | | **Cloudflare Pages** | Free | **500 builds/月** | 1 个并发构建;无限带宽;100 个自定义域名/项目 | [Cloudflare Pages 官方](https://pages.cloudflare.com/) | | **GitHub Pages** | Free(公开仓库) | **10 builds/小时**(软限制) | 单次构建 10 分钟超时;100 GB/月 带宽;1 GB 站点大小 | [GitHub Docs](https://docs.github.com/en/pages/getting-started-with-github-pages/about-github-pages) | | **GitLab Pages** | Free(GitLab.com) | **400 CI minutes/月/组**(私有仓库) | 公开仓库可通过 OSS 计划申请更多;Pages 本身无额外构建限制,消耗 GitLab CI 额度 | [cicdcalculator.com](https://cicdcalculator.com/free-ci-cd-platforms) | | **Render** | Free | **500 build minutes/月** | 静态站点不 sleep;Web Service 15 分钟无活动后 sleep;100 GB 带宽 | [deploybase.app](https://deploybase.app/blog/render-free-tier-complete-guide-2026) | | **Railway** | Free / Trial | 无明确 "build minutes" 配额,按 **usage-based** 计费;Trial 含 \$5 一次性额度,Free 计划 \$1/月 | 构建时间按 CPU/RAM 消耗 credits;30 天 Trial 后需升级或转入 Free 计划 | [Railway 官方定价](https://railway.com/pricing) | | **Firebase Hosting** | Spark(免费) | **无内置 CI 构建分钟限制**(Hosting 本身只提供托管,CI 需配合 GitHub Actions 等外部工具) | 1 GB 存储;10 GB/月 传输流量;需配合 Blaze 计划才支持 Cloud Functions | [Scrimba 2026 Firebase 指南](https://scrimba.com/articles/best-firebase-tutorials-and-projects-2026/) | | **AWS Amplify** | Free Tier | **1,000 build minutes/月** | 5 GB 存储;超出后 \$0.01/build minute | [urancompany.com](https://urancompany.com/blog/aws-amplify-and-serverless-web-development) | | **Surge.sh** | Free | **无明确限制**(Unlimited publishing) | 仅支持静态站点;自定义域名免费;SSL 仅对 \*.surge.sh 子域名自动提供 | [sunlightmedia.org](https://sunlightmedia.org/using-surge-for-deploying-static-sites/) | 下次可不能浪费了,**省着点用吧** 😅 --- ## 人工智能 Poster 专业展示 URL: https://xingwangzhe.fun/posts/week3-ai-poster/ License: CC-BY-NC-SA-4.0 ## 又一个 `Poster` 看了一下他们 `人工智能专业` 的设计,果然是专业的——还有 **纯英文** 的,而且听老师说他们的时间比我们的更短,好像只有三天准备? 还有满墙的 `专有名词` 我一个都看不明白。可能为数不多能看懂的就是 `AI` 或者 `Artificial Intelligence` 了吧…… ![AI 专业展出的各类学术海报](/AI/poster-exhibition.webp) ### 人头攒动 现场依旧非常热闹,但是由于有了上一次的经验,很多人贴完 `小红花` 之后就跑路了。 ![展厅内人头攒动](/AI/poster-crowd.webp) 但我没有。我只是 ~~装模作样~~ 地逛逛,问一些我完全不理解、也可能听不太懂的问题。 > **主力还是去当学术蝗虫**🦗 > > 整场我不是在吃零食,就是在坐着吃零食,顺便也省了晚饭钱。 --- ## 第一次做学术 Poster——人工智能实训 Week2 的报告体验 URL: https://xingwangzhe.fun/posts/ai-week2end-poster/ License: CC-BY-NC-SA-4.0 > **`Poster`(学术海报)** 是学术会议中常见的一种展示形式。与传统的 PPT 演讲截然不同: > > | | PPT 演讲 | `Poster` 报告 | > | -------- | ------------------ | -------------------------------- | > | **形式** | 单人上台,投影讲解 | 实体海报(A0 竖版),旁站讲解 | > | **流程** | 一对多,串行 | **多对多并发**,观众自由流动 | > | **内容** | 线性叙事,可展开 | **高度凝练**,3-5 分钟抓住注意力 | > | **互动** | 结束后统一 Q&A | 随时提问,边看边聊 | > > 这意味着你的 `Poster` 必须做到: > > - **标题要醒目**——远距离就能看到关键词 > - **图表要直观**——一图胜千言,不能让观众凑近看小字 > - **结论要突出**——路过的人扫一眼就知道你做了什么 > - **讲解要精炼**——30 秒电梯演讲、3 分钟标准版、10 分钟深度版,得准备好三个版本 --- ## 一次有趣的 `Poster` 报告体验 与以往各种课题的小组报告不同,`Poster` 报告更像是一种**展示**、**路演**,确实有那么一点学术氛围。 我们组的主题是 **Agent 智能体开发**,海报覆盖了 B1 到 B4 四个模块。我的部分是 **B4 LLM 决策模块**——也就是前两篇文章 [Day4 Proposal](https://xingwangzhe.fun/posts/ai-training-agent-day4-proposal/) 和 [Week2 实战](https://xingwangzhe.fun/posts/ai-training-b4-llm-week2) 里写的那个智能体大脑。 海报上 B4 部分重点展示了实验结果:**工具调用准确率 92% 以上**,单次 LLM 决策耗时 **1-2 秒**,解析成功率 **98%**。配套的还有故障容错(`State Machine` + `Checkpoint` 断点恢复)、动态上下文压缩防 Token 溢出、`max_turns` 轮次防护等工业级设计。 说实话,要把这些内容浓缩到一张海报的一个模块里,比写代码难多了。 --- ### 入场 活动的基本信息如下: > **计算机 `Poster` 展示活动** > > - **时间**:7 月 2 日 下午 3:00 > - **地点**:生科 B 四楼中厅 > - **流程**:各小组同时展示 → 贴小红花评分 → 颁奖 下午 2:40 从信息楼 A218 出来,走小路很快就到了生科楼。 到的时候,老师和助教已经摆好了各组的 `Poster`,沿着走廊排开,还挺壮观的。我们各组找到自己的位置站好,等人来。 没过多久,人工智能系的同学入场了,人群一下子热闹起来。 ![poster交流现场](/ai-week2end-poster/poster交流现场.webp) 旁边老师还准备了一些零食——这倒是意外之喜。 ![零食](/ai-week2end-poster/零食.webp) --- ### 交流询问 说实话,我们的位置有点"风水问题"(?)。靠北的里面那几个位置的 `Poster` 人聚了一大圈,反倒是我们这边比较冷清。分析一下原因的话,可能还是标题不够抓眼球——"Agent 智能体开发"这个表述偏学术,路过的人看一眼不一定能立刻反应过来这是做什么的。 不过还是有了一位老师和一位学长过来问。我们组三个人,各负责一部分。老师问了**各个模块的分工**——B1 运行时管理、B2 技能函数层、B3 工具调用层、B4 LLM 决策引擎——每个人都讲了自己那部分。 嗯,最后得了**两个小红花**和老师的大拇指,还算不亏。 ![我们的poster](/ai-week2end-poster/我们的poster.webp) 说到方向分布,B 方向(`Agent` 与工具调用)果然是最热闹的。想想也能理解——`Agent` 开放给用户的直观感受比训练模型调参数强太多了,毕竟谁都能说一句"让 AI 执行工具",而 `loss` 曲线就不是谁都有兴趣看的。 虽然说活动安排是 30-40 分钟,实际上差不多逛了一个小时。 --- ### 颁奖 最后老师开始颁奖、讲话,前三名的小组拿了奖。 活动圆满结束。`Poster` 我就带回寝室了——说实话,卷起来还挺占地方的。 ![老师讲话](/ai-week2end-poster/老师讲话.webp) --- ## 一点感想 这次 `Poster` 报告让我意识到一件事:**把技术讲清楚,有时候比做出技术更难**。 写代码的时候,你可以沉浸在 `State Machine` 的状态转移和 `Checkpoint` 的断点恢复逻辑里;但站在 `Poster` 前面,面对一个路过的人,你只有 3 秒决定他会不会停下来。这 3 秒里,他看不到那 92% 准确率背后的批量测试脚本——他只看得到标题和一目了然的数据。 下次再做的话,我会把 "92% 工具调用准确率" 放到最大的字号。因为**数字比任何描述都有说服力**。 --- ## AI + Rust 两连发:从 20 分钟到 12 秒,博客宇宙的构建优化之旅 URL: https://xingwangzhe.fun/posts/ai-rust-bfs-force-two-wheels/ License: CC-BY-NC-SA-4.0 ![博客宇宙](/ai-rust-bfs-force-two-wheels/cover.webp) ## 背景:一个项目,两个瓶颈 [FriendLinks](https://github.com/xingwangzhe/FriendLinks) 是一个博客宇宙可视化项目。它爬取数千个独立博客的友链数据,在构建时生成两个核心产物: | 产物 | 说明 | | ----------------- | -------------------------------------------------------------------------- | | **`/graph.bin`** | 3D 力导向图二进制数据,46839 节点、89454 边,前端渲染为可交互的博客宇宙 | | **`/stats.json`** | 六度分隔统计数据,56941 节点的全连通图 BFS,展示任意两个博客之间的平均距离 | 每次 `astro build`,这两个端点各占一半的构建时间。而且都很慢。 --- ## 第一个瓶颈:stats.json 的 BFS 六度分隔的核心是 All-Pairs Shortest Paths——对图中每一个节点做 BFS,统计距离分布。 ### 旧方案:JS 抽样(无奈之举) 56941 个节点,做全量 BFS 的复杂度是 $O(n \times (n+m))$。在 JavaScript 里,这根本跑不完。 所以旧代码做了妥协:**只在最大连通分量里随机抽 3000 个节点做 BFS,然后把距离分布按比例放大**。 ```typescript title="采样 BFS(旧代码)" // 旧:采样 BFS(约 Jul 1) const sampledNodes = shuffle(largestComponent).slice(0, 3000); for (const a of sampledNodes) { // 手动 TypedArray BFS,队列复用,内联计数... } // 除以 2(无向图双向计数),按节点比例放大... ``` 经过几次优化后(TypedArray 复用、内联计数),3000 个节点的 BFS 已经跑得动了——但还是**抽样估算**,不是精确值。而且整个 `/stats.json` 端点还是要花相当长时间。 ### 新方案:Rust 全量 BFS 7 月 3 日晚上,我让 AI 用 Rust + [NAPI-RS](https://napi.rs/) 写了一个 BFS 原生模块——`@xingwangzhe/bfs-rs`。 核心是 CSR(Compressed Sparse Row)格式的邻接表 + Rayon 并行: ```rust title="bfs-rs: 全量 BFS(Rust)" // bfs-rs: 全量 BFS,Rayon 并行 pub fn bfsMergedHistogram(adj: Vec, offsets: Vec, n: u32) -> MergedHistogram { // Rayon par_chunks(500),每个 chunk 从不同源节点并行 BFS // Mutex 保护的共享 histogram,Rust 侧直接聚合,不返回到 JS } ``` JS 侧从几百行手动 BFS 循环简化成一次函数调用: ```typescript title="bfsMergedHistogram 调用" // 新:一行调用 const merged = bfsMergedHistogram(adjArr, offArr, n); // merged.histogram 就是全量 56941 节点的精确距离分布 ``` 升级过程也很有意思——第一版 `bfsAll` 返回了所有 $n \times n$ 个距离,直接 $O(n^2)$ 内存 segfault。改成 `bfsBatch` 分批处理,最后进化到 `bfsMergedHistogram`,Rust 侧用 Mutex 聚合 histogram,JS 侧只需要接收最终结果。 **从"抽样估算"到"全量精确",同样的时间甚至更快。** --- ## 第二个瓶颈:graph.bin 的力导布局 3D 力导向图需要把 46839 个节点布局到三维空间。用的是经典的 Barnes-Hut N-body 模拟 + 弹簧力 + 中心力。 ### 旧方案:d3-force-3d(20 分钟) 最初用 `d3-force-3d`,效果很好,但**太慢了**。JavaScript 里单 tick 就要约 1 秒,收敛需要 300+ tick。每次构建光力导仿真就要 **5 分钟以上**。代码里设了 14 分钟的超时上限——在 CI 上经常超时。 ```text title="构建日志(旧版)" 构建日志(旧版): tick 50/∞ α=0.3642 46839 节点 (···) tick 100/∞ α=0.1326 46839 节点 (···) ... ✔ 力导仿真完成 · 300 tick · 320.5s ← 5 分多钟! ``` 期间调整过无数次参数——theta 从 0 调到 2.5,TICKS 从 800 砍到 15,repulsion 从 400 改到 3000——但始终突破不了 JS 单线程的天花板。 ### 新方案:force-rs(12 秒) 7 月 4 日,让 AI 用 Rust 完成了 Barnes-Hut 八叉树 + Rayon 并行。`d3-force-3d` 的完整力模型——多体斥力、degree-biased 弹簧力、质心平移——全部移植到 Rust,API 简化成一个函数: ```typescript title="force-rs API 调用" const state = new Float64Array(n * 6 + 1); // [x,y,z,vx,vy,vz, ...alpha] const links = new Uint32Array(edges.length * 2); const opts = { repulsion: 3000, linkDistance: 500, centerStrength: 0.005, theta: 0.8, velocityDecay: 0.6, alphaDecay: 0.02, }; for (let i = 0; i < TICKS_MAX; i++) { state = simTick(state, links, n, opts); // 一行 } ``` 编译、运行——输出正常。但打开 `.bin` 文件,坐标炸了: ```text title="坐标溢出(调试日志)" 范围: [ -5,424,070,723 , 5,165,802,560 ] ← ±54 亿! ``` 接下来是一段精彩的调试过程。AI 加了逐 tick 的 debug 输出: ```text title="逐 tick debug 输出" tick 1: max_rep=2.75e3 max_spr=1.52e3 max_v=1.87e3 ← 正常 tick 2: max_rep=4.0e1 max_spr=2.07e4 max_v=1.10e4 ← 弹簧力涨了 10 倍! tick 3: max_rep=2.32e1 max_spr=8.36e4 max_v=4.16e4 ← 又涨了 4 倍! ... tick 10: max_rep=0.04 max_spr=1.70e9 max_v=7.28e8 ← 弹簧力 17 亿! tick 12: max_rep=0.00 max_spr=2.12e10 max_v=6.17e9 ← 超 clamp 上限! ``` 排斥力在正常衰减,但**弹簧力每 tick 指数增长约 4 倍**。隔离测试发现:无连接时坐标正常,问题一定在弹簧力。 AI 逐行对比了 `d3-force-3d` 的 link force 源码,发现了根因——**bias 方向写反了**: ```rust title="bias 公式修正" // ❌ 错误:用自己的度数做 bias → 高度数节点受力大,低度数叶子节点被吹飞 let bias = my_deg / (my_deg + deg[ni] as f64); // ✅ 正确:用对方的度数做 bias(d3-force 行为)→ 叶子节点被锚定 let bias = deg[ni] as f64 / (my_deg + deg[ni] as f64); ``` d3-force 的设计里,degree=1 的节点应该获得 91.7% 的弹簧力,这样才不会被 46839 个节点的排斥力吹飞。bias 一反过来,38000+ 个叶子节点每人只拿到 8% 的力,直接原地升天。 **一行代码,几个小时的调试,坐标从 ±54 亿变回正常的 ±26 万。** 除此之外还修复了 4 个其他偏差(中心力模型、近距离软化、Barnes-Hut 修正因子、$\theta^2$ 判定)。修复后 14 个测试全过。 --- ## 性能对比 | 端点 | 旧方案 | 新方案 | 提升 | | --------------------- | ----------------------------------- | ------------------------------------ | ----------------- | | `/graph.bin` 力导仿真 | d3-force-3d JS,~5 分钟(300 tick) | force-rs Rust,**~8 秒**(100 tick) | **~40x** | | `/stats.json` BFS | JS 抽样 3000 节点,估算 | bfs-rs Rust,**全量 56941 节点精确** | 从估算变精确 | | 单 tick(47K 节点) | ~1.0 秒 | **~0.08 秒** | **~12x per tick** | | 整体构建 | ~20 分钟 | **~40 秒** | **~30x** | 整体构建从 20 分钟压到了 40 秒左右。更关键的是,`graph.bin` 从 300 tick 降到了 100 tick 就能收敛(因为 Rust 的力模型和 d3-force 一致的精度),而 `stats.json` 从"抽样估算"变成了"全量精确"。 --- ## CI/CD:自动多平台发布 两个包都用 GitHub Actions 做了自动化。拿 force-rs 举例: ```yaml title=".github/workflows/CI.yml" # .github/workflows/CI.yml build: strategy: matrix: - target: x86_64-apple-darwin - target: aarch64-apple-darwin - target: x86_64-pc-windows-msvc - target: x86_64-unknown-linux-gnu - target: x86_64-unknown-linux-musl - target: aarch64-unknown-linux-gnu ``` 每次推送,CI 在 6 个平台上编译、跑 18 个 node 版本组合的测试、然后把所有 `.node` 二进制打成一个 1.3MB 的 npm 包发布,理论上可以发几个子包来减小大小,但那样需要设置npm,我懒。 ```bash title="安装两个 npm 包" bun add @xingwangzhe/bfs-rs # 全量精确 BFS bun add @xingwangzhe/force-rs # 百倍加速力导布局 ``` 在 FriendLinks 项目里,只需要 `bun update` 就能用上最新版。 --- ## 时间线 | 日期 | 事件 | | ------------------ | -------------------------------------------------------------------------------------------- | | 6 月底 ~ 7 月 2 日 | 反复调参 d3-force-3d,始终突破不了 JS 瓶颈 | | 7 月 1 日 | 用 JS 实现采样 BFS(最大 3000 节点),优化到可接受速度 | | **7 月 3 日 晚** | **AI 写出 bfs-rs v0.1.0**:从 `bfsAll` 到 `bfsBatch` 到 `bfsMergedHistogram`,一晚上三次迭代 | | **7 月 4 日 上午** | **AI 写出 force-rs**:Barnes-Hut + Rayon。调试发现 bias 反转,修复后发布 v0.1.2 | | 7 月 4 日 下午 | FriendLinks 全面切换到两个 npm 包,构建从 20 分钟降到 40 秒 | **两个晚上,两个 Rust 轮子,一次彻底的构建优化。** --- ## 感想 这次经历让我对两个东西有了新的认识: | | 认识 | | ----- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **1** | **NAPI-RS 的门槛比想象的低。** 一个 ~200 行的 `lib.rs` + 一个标准 `Cargo.toml`,就能把 JavaScript 里最慢的部分替换成 Rust。状态直接在 `Float64Array` 和 `Vec` 之间零拷贝传递,不需要序列化。NAPI-RS 生成的 `index.js` 自动处理了平台检测和 fallback。 | | **2** | **Rust是AI的Native语言。** 我不擅长 Rust——借用检查器、生命周期标注、Rayon 的 `Send + Sync` 约束——这些对 Rust 新手来说都是拦路虎。但 AI 帮我处理了这些细节,让我能聚焦在"力模型是否正确"这种更高层次的问题上。最让我印象深刻的是调试环节:AI 能同时阅读 `d3-force-3d` 的 JS 源码和 `force-rs` 的 Rust 源码,逐行对比力模型公式,发现了那一行 bias 反转。这种跨语言的细致审计,如果纯人工做,可能要多花好几天。 | 一个晚上 BFS,一个晚上力导布局。从"构建太慢了先玩会手机"到"咦这就跑完了"。 项目链接: - [@xingwangzhe/bfs-rs](https://github.com/xingwangzhe/bfs-rs) — Rust 全量精确 BFS - [@xingwangzhe/force-rs](https://github.com/xingwangzhe/force-rs) — Rust Barnes-Hut 力导布局 [FriendLinks](https://github.com/xingwangzhe/FriendLinks)([links.needhelp.icu](https://links.needhelp.icu))是一个博客宇宙可视化项目。 --- ## 人工智能实训Week2:B4 LLM决策模块五维升级实战 URL: https://xingwangzhe.fun/posts/ai-training-b4-llm-week2/ License: CC-BY-NC-SA-4.0 > **前置声明:本图文存在AI辅助整理** 前文 [Day4 Proposal](https://xingwangzhe.fun/posts/ai-training-agent-day4-proposal/) 中我详细分析了 B4 LLM 决策模块的设计方案 本文在 Proposal的架构基础上,逐一攻克五个进阶要求,让一个 4B 小模型跑出了**远超预期**的工具调用能力。 先回顾一下 B4 在 Agent 系统中的位置: ```mermaid flowchart TD User["用户"] B1["B1 Runtime
运行时管理"] B4["B4 LLM决策
(本篇)"] B3["B3 Tool Layer
工具调用层"] B2["B2 Skill
技能函数层"] User --> B1 B1 --> B4 B4 --> B1 B1 --> B3 B3 --> B2 B2 --> B3 B3 --> B1 B1 --> User ``` B4 是系统唯一的"大脑"——所有工具调用的决策都在这里发生。Proposal 中设计的基础版已经实现了 ReAct 单步调用(`choose exactly one tool`),进阶要求需要在此基础上做五件事: ```mermaid mindmap root((B4 进阶
五维升级)) 一_多工具并发 单轮 N 个 tool_calls 单轮接收 N 个 ToolMessage "zero, one, or multiple" 二_Plan_Execute 先生成计划 逐步执行 两阶段 prompt 设计 三_模型切换 命名 profiles CLI 参数选择 _MODEL_CACHE 天然支持 四_传参对比 prompt 注入 vs builtin 意外的 XML 输出 0% vs 83.3% 五_批量统计 6 个测试样例 成功率 + token + 延迟 发现 calculator 互斥 bug ``` 下面按顺序逐一拆解。 --- ## B4 进阶总览 在 Day4 Proposal 中我提到:基础版有三个核心约束——prompt 要求"choose exactly one tool"、Mock 只返回一个 `tool_call`、工具调用每次只有一轮闭环。进阶要求就是逐个打破这些约束。 五个改动从简单到复杂,存在一定的依赖关系: | 顺序 | 进阶要求 | 依赖 | 改动量 | 核心难度 | | :--: | ---------------- | ---- | :----: | :------------------------------: | | 1 | 多工具并发 | 无 | 中 | Prompt 模板重构 | | 2 | Plan-and-Execute | 无 | 大 | 新增 4 个函数 + 两阶段设计 | | 3 | 模型切换 | 无 | 小 | 仅 `_load_model_config` 加 10 行 | | 4 | 传参方式对比 | 无 | 中 | 新增 `tool_calling` 模式分支 | | 5 | 批量统计 | 1-4 | 大 | 独立脚本 `b4_batch_benchmark.py` | > 说实话,这五个里面最让我意外的是第 4 个——传参方式对比。原以为 builtin 是"正道",prompt 注入是"野路子",结果跑出来的数据令人意外。这个后面细说。 --- ## 一、单轮多 tool_calls + 多 ToolMessage ### 痛点 Day4 Proposal 的基础版有两个硬性限制: 1. **Prompt 层面**:`format_instruction` 中明确写死 "Choose exactly one schema: final content with an empty tool_calls array, or empty content with tool calls." 2. **Mock 层面**:`_mock_generate` 只返回一个 `tool_call`,固定是 `file_reader` 这意味着用户说"帮我读文件、顺便算个数、再搜个东西"时,模型被 prompt 约束,**只能一件一件来**——三轮 ReAct 循环,每轮一次推理,效率低得累死。 ### 改了什么 改动集中在两个核心函数:`_build_prompt_messages` 和 `_mock_generate`。 #### 改动 1:Prompt 模板——从 "choose one" 到 "zero, one, or multiple" ```diff lang="python" title="b4_local_agent_llm.py — _build_prompt_messages()" "Valid schema A (final answer, no tools needed):\n" '{"content":"final answer text","tool_calls":[]}\n\n' - "Valid schema B:\n" + "Valid schema B (call one or more tools, content must be empty):\n" '{"content":"","tool_calls":[{"id":"call_001","name":"file_reader",' - '"args":{"path":"docs/agent_intro.txt","max_chars":2000}}]}\n\n' + '"args":{"path":"docs/agent_intro.txt","max_chars":2000}},{"id":"call_002",' + '"name":"calculator","args":{"expression":"2+2"}}]}\n\n' ... - "Choose exactly one schema: final content with an empty tool_calls array, or empty content with tool calls. " + "You may include zero, one, or multiple tool_calls in the array. " ``` Schema B 示例从 1 个 `tool_call` 扩展到 2 个——`file_reader` + `calculator`——给模型一个"多工具调用"的具体范例。提示语从 "choose exactly one" 改为 "zero, one, or multiple"。 说实话,4B 参数的模型,能理解"并发调用多个工具"这个概念吗?但 Qwen3.5-4B 的表现让我吃惊。后面的验证数据会证明这一点。 #### 改动 2:多 ToolMessage 尾部处理 ```diff lang="python" title="b4_local_agent_llm.py — _build_prompt_messages() 尾部" - if prompt_messages[-1].get("role") == "tool": + if prompt_messages and prompt_messages[-1].get("role") == "tool": + tool_count = sum(1 for m in reversed(prompt_messages) if m.get("role") == "tool") prompt_messages.append({ "role": "user", "content": envelope_reminder - + " The latest ToolMessage already contains a tool result..." + + f" The last {tool_count} ToolMessage(s) contain tool results. If they provide the requested " + 'information, answer with schema A now and set "tool_calls" to exactly []. Do not repeat the ' + "completed tool calls." }) ``` 不再假设只有一条 ToolMessage,而是用 `reversed` 动态统计连续的数量,在 prompt 中明确告知模型"你刚才调了 N 个工具,结果都在这里了,请用 schema A 回答"。这个小改动的价值在于——它把**元信息**(这次发起了几个并发调用)传递给了模型,减少了模型"忘掉自己刚才做了什么"的概率。 #### 改动 3:Mock 模式——遍历所有 ToolMessage ```diff lang="python" title="b4_local_agent_llm.py — _mock_generate()" - latest = tool_messages[-1] - result = _extract_tool_result(latest) - if latest.get("status") != "success" or result.get("status") != "success": - ... - output = result.get("output") or {} - content = output.get("content") if isinstance(output, dict) else None + # 遍历所有 ToolMessage,逐个检查状态 + for tm in tool_messages: + if tm.get("status") != "success": + ... + return make_ai_message(f"工具调用失败,无法完成请求:{detail}", []) + # 全部成功则汇总所有 ToolMessage 的结果 + summaries = [] + for tm in tool_messages: + try: + result = _extract_tool_result(tm) + output = result.get("output") or {} + content = output.get("content") if isinstance(output, dict) else None + if isinstance(content, str) and content.strip(): + summaries.append(content) + except ValueError: + pass + combined = "\n".join(summaries) if summaries else "工具结果未提供可提取内容" ``` Mock 模式现在不再只看最后一条 ToolMessage,而是遍历所有、检查每条的状态、汇总所有结果。失败策略也变了:**任一失败则整体失败**(fail-fast),而不是只看最后一条。 ### 验证 用真实 Qwen3.5-4B 测试,分三个梯度验证: | 并发度 | 场景 | Mock | 真实模型(Qwen3.5-4B) | 状态 | | :----: | --------------------- | ---------------------------------------- | :-----------------------------------------------------: | :------: | | 2 | 生成 2 个 tool_calls | [OK] `file_reader` + `local_file_search` | [OK] `file_reader` + `calculator` | **通过** | | 2 | 接收 2 条 ToolMessage | [OK] 合并两个工具结果 | [OK] 输出完整回答 | **通过** | | 3 | 生成 3 个 tool_calls | [OK] 固定 2 个(Mock 限制) | [OK] `file_reader` + `calculator` + `local_file_search` | **通过** | | 3 | 接收 3 条 ToolMessage | [OK] 合并三个工具结果 | [OK] 输出 "1)... 2) 3.14\*5=15.7 3) 搜索到..." | **通过** | | 5 | 接收 5 条 ToolMessage | [OK] 合并五个工具结果 | — | **通过** | 最高测试到 **5 并发 tool_calls**,Mock 模式完美通过。最具说服力的是真实模型的 3 并发测试: **输入**: "帮我做三件事:1) 阅读 docs/agent_intro.txt;2) 计算 3.14 \* 5;3) 搜索包含'Agent'的文件。" **模型输出 tool_calls**: ```json title="Qwen3.5-4B 真实输出 (3并发tool_calls)" [ { "id": "call_001", "name": "file_reader", "args": { "path": "docs/agent_intro.txt", "max_chars": 2000 } }, { "id": "call_002", "name": "calculator", "args": { "expression": "3.14 * 5" } }, { "id": "call_003", "name": "local_file_search", "args": { "query": "Agent", "max_results": 5 } } ] ``` 三个工具结果全部返回后,模型合并输出: > 1. docs/agent_intro.txt 内容:Agent 系统通常由模型、工具、记忆和执行循环组成... > 2. 3.14 \* 5 = 15.7 > 3. 搜索到包含'Agent'的文件:docs/agent_intro.txt **status: success** [OK] > 说实话,看到这个结果的时候我乐了——就改了三行 prompt,一个 4B 的小模型就能从"单步调用"进化到"三并发"。不是说 4B 模型能力不够,而是**prompt 给它的"自由度"决定了它的行为边界**。 --- ## 二、Plan-and-Execute 计划执行模式 ### ReAct vs Plan-and-Execute 在 Day4 Proposal 中,基础版 B4 遵循的是 **ReAct**(Reasoning + Acting)范式: ```text title="ReAct 范式流程示意" ReAct: User → 推理决策1 → 工具1 → 推理决策2 → 工具2 → ... → 最终回答 ``` 每一步都要过一次 LLM——做一次推理、调一个工具、看结果、再推理……这在简单任务上没问题,但复杂任务会导致多轮调用、token 消耗翻倍、延迟累积。 **Plan-and-Execute** 的思路完全不同: ```text title="Plan-and-Execute 范式流程示意" PlanEx: User → 生成完整计划 → 步骤1执行 → 步骤2执行 → ... → 最终回答 ``` 模型先"通盘考虑"生成一个有序步骤计划,然后逐步执行。类比一下:ReAct 是边想边做,Plan-and-Execute 是先列清单再逐项打勾。 ### 实现方案 新增 `plan_execute` 模式,分两个阶段: ```mermaid flowchart TD User["用户请求"] --> Plan["阶段1: 计划生成
plan_execute mode
无 ToolMessage"] Plan --> PlanPrompt["_build_plan_prompt_messages()
引导模型输出计划 JSON"] PlanPrompt --> PlanOutput["输出: reasoning + plan 数组
每步含 step, description, tool_call"] PlanOutput --> StepExec["阶段2: 步骤执行
plan_execute mode
有 ToolMessage"] StepExec --> StepPrompt["_build_plan_step_prompt_messages()
告知上一步已完成"] StepPrompt --> Decision{"继续 or 结束?"} Decision -- "还有步骤" --> NextStep["输出下一个 tool_calls"] NextStep --> StepExec Decision -- "全部完成" --> Final["最终回答"] ``` ### 新增的四个核心函数 | 函数 | 职责 | 关键设计 | | ------------------------------------ | ------------------------------------ | ---------------------------------------------------------------------------------- | | `_build_plan_prompt_messages()` | 引导模型输出计划 JSON | 在 prompt 中给出 `{"reasoning":"...","plan":[{step,desc,tool_call},...]}` 格式示例 | | `_build_plan_step_prompt_messages()` | 告诉模型"上一步已完成,继续 or 结束" | 区分"还有剩余步骤"和"全部完成"两种情况 | | `_parse_plan_output()` | 解析计划输出 | 兼容 3 种格式:标准 plan / 仅有 plan 数组 / 标准 AIMessage(不做 plan 解析) | | `_mock_plan_execute()` | Mock 模式生成 3 步计划 | `file_reader → local_file_search → calculator` 固定序列 | 其中 `_parse_plan_output` 的兼容性设计值得一提——它支持三种输入格式: 1. **标准 plan 输出**:`{"reasoning":"...", "plan":[...]}` 2. **仅有 plan 数组**:`[...]`(模型有时会省略 reasoning) 3. **标准 AIMessage**:已有 `content` 和 `tool_calls`,直接透传 > 这个灰度兼容策略的灵感直接来自 Day4 Proposal 的三层解析设计——永远不要假设模型的输出格式是完美的。 ### 真实的 Qwen3.5-4B 计划输出 ```json title="Qwen3.5-4B Plan-and-Execute 计划生成" { "reasoning": "First, I need to read the file at docs/agent_intro.txt. Then, I will search for all files containing 'Agent'. Finally, I will count and summarize three key points.", "plan": [ { "step": 1, "description": "Read the agent intro file", "tool_call": { "id": "call_001", "name": "file_reader", "args": { "path": "docs/agent_intro.txt", "max_chars": 2000 } } }, { "step": 2, "description": "Search for files containing 'Agent'", "tool_call": { "id": "call_002", "name": "local_file_search", "args": { "query": "Agent", "max_results": 5 } } } ] } ``` 步骤执行后,模型合并结果输出最终回答: > 已完成任务: > > 1. 文件数量:2个(docs/agent_intro.txt, docs/agent_guide.md) > 2. 要点总结:Agent 系统由模型、工具、记忆和执行循环组成... ### 验收 | 场景 | Mock | 真实模型(Qwen3.5-4B) | 状态 | | ------------------- | ------------- | ----------------------------------- | -------- | | 计划生成 | [OK] 3 步计划 | [OK] 2 步计划 + reasoning | **通过** | | 步骤执行 → 最终回答 | [OK] 合并结果 | [OK] "已完成任务: 2个文件, 3条要点" | **通过** | > Plan-and-Execute 的**两阶段 prompt 设计**是我觉得整个 B4 进阶中最"优雅"的设计。计划生成阶段的 prompt 需要给模型充分的自由度和结构化的计划格式引导;而步骤执行阶段的 prompt 则需要约束模型"你已经有了计划,现在按计划行事,别乱改"。两个阶段的目标不同,prompt 自然也不同。 --- ## 三、模型动态切换 ### 从 \_MODEL_CACHE 说起 Day4 Proposal 中设计了 `_MODEL_CACHE` 全局缓存字典,缓存键包含: $$cache\_key = hash(model\_path \parallel tokenizer\_path \parallel dtype \parallel device\_map \parallel max\_memory)$$ 这个设计的精妙之处在于——**它天然支持多模型**。只要缓存键不同,就会自动触发 cache miss 并加载新模型。切换任何配置参数都会自动触发重新加载。 所以进阶要求"支持模型切换"的改动量其实非常小——只需要一个 name resolver,把用户选择的 profile name 映射到对应的配置参数。 ### 设计思路 在 `model.yaml` 中新增 `models` 节,定义多个命名 profile: ```yaml title="model.yaml — 新增 models 节" models: qwen-4b: display_name: Qwen3.5-4B (standard) backend: transformers model_name_or_path: /root/assignment_B/Qwen3.5-4B torch_dtype: bfloat16 device_map: auto do_sample: false temperature: 0 max_new_tokens: 1024 max_input_tokens: 4096 qwen-4b-fast: display_name: Qwen3.5-4B (fast mode) backend: transformers model_name_or_path: /root/assignment_B/Qwen3.5-4B torch_dtype: bfloat16 device_map: auto do_sample: false temperature: 0 max_new_tokens: 512 # ← 更短的生成长度 max_input_tokens: 4096 model: # 默认配置,兼容旧用法 backend: transformers ... ``` 同一个物理模型 `Qwen3.5-4B` 可以有不同的生成参数配置——standard 模式 `max_new_tokens=1024`,fast 模式 `max_new_tokens=512`。未来接入不同路径的模型(比如 Qwen3.5-7B)也能直接复用这套机制。 ### 代码改动——极简 10 行 ```python title="b4_local_agent_llm.py — _load_model_config() 新增" def _load_model_config(model_config, model_name=None): path, config = _read_yaml(model_config) if model_name: models_section = config.get("models", {}) if model_name not in models_section: available = list(models_section.keys()) raise ValueError(f"Unknown model_name '{model_name}'. Available: {available}") selected = deepcopy(models_section[model_name]) config["model"] = selected print(f"model: {selected.get('display_name', model_name)}", file=sys.stderr) return path, config ``` CLI 加 `--model_name` 可选参数。未知 model_name 会列出所有可用选项并报错——用户体验细节不能少。 ### 效果演示 ```bash title="终端 — 模型切换" # 默认模型(不指定 --model_name) python b4_local_agent_llm.py --model_config ../configs/model.yaml \ --messages ../data/messages/messages_no_tool.json \ --tools_schema ../data/messages/tools_schema_basic.json \ --mode mock --outdir ../outputs/B4_llm/model_switch_default # 切换 qwen-4b python b4_local_agent_llm.py --model_config ../configs/model.yaml \ --model_name qwen-4b --mode prompt_json \ --outdir ../outputs/B4_llm/model_switch_qwen # stderr: model: Qwen3.5-4B (standard) # 切换 qwen-4b-fast python b4_local_agent_llm.py --model_config ../configs/model.yaml \ --model_name qwen-4b-fast --mode prompt_json \ --outdir ../outputs/B4_llm/model_switch_fast # stderr: model: Qwen3.5-4B (fast mode) ``` | 测试 | 命令 | 输出 | 状态 | | --------------------- | ---------------------------------------------- | ------------------------------- | ---- | | 默认 (不指定) | `--mode mock` | 无 model 输出 | [OK] | | qwen-4b | `--model_name qwen-4b` | `model: Qwen3.5-4B (standard)` | [OK] | | qwen-4b-fast | `--model_name qwen-4b-fast` | `model: Qwen3.5-4B (fast mode)` | [OK] | | qwen-4b 真实推理 | `--model_name qwen-4b --mode prompt_json` | 正常加载推理 | [OK] | | qwen-4b-fast 真实推理 | `--model_name qwen-4b-fast --mode prompt_json` | 正常加载推理 | [OK] | > 说实话,模型切换是整个 B4 进阶中**改动量最小但设计感最强的**一个。`_MODEL_CACHE` 在 Proposal 阶段就考虑到了扩展性,现在只是加了一个薄薄的 name resolver。好架构的特点是:当新需求来临时,改动集中在最薄的接口层。 --- ## 四、tools_schema 传参方式对比 ### 实验设计 这是五个进阶要求中最有意思的一个——把 tools_schema **注入 prompt 文本**(prompt 注入)vs 通过 `tokenizer.apply_chat_template(tools=...)` **原生传入**(builtin 内置),两种方式谁更可靠? Day4 Proposal 中的双保险 prompt 策略(`format_instruction` + `envelope_reminder`)和三层降级解析,全部基于一个前提:**模型输出的是 JSON 格式**。 于是我做了一个对比实验: | | prompt_json(prompt 注入) | builtin(内置传参) | | --------------------- | ----------------------------------- | -------------------------------------- | | **tools_schema 位置** | system message 文本中,约 500 token | chat template 的 `tools` 参数,0 token | | **模型理解方式** | 从文本理解工具描述 | 训练中习得的工具调用格式 | | **解析器预期** | JSON | JSON | | **理论优势** | 完全可控,双保险策略兜底 | 节省 token,利用模型原生能力 | ### 实际运行——结果完全出乎意料 **prompt_json 模式输出**(符合预期 [OK]): ```json title="prompt_json 模式 — 模型输出" { "content": "", "tool_calls": [ { "id": "call_001", "name": "file_reader", "args": { "path": "docs/agent_intro.txt", "max_chars": 2000 } } ] } ``` **builtin 模式输出**(意外): ```xml title="builtin 模式 — 模型输出(注意:这是XML不是JSON!)" docs/agent_intro.txt 2000 ``` Qwen3.5-4B 的内置工具调用输出的**不是 JSON,而是原生 XML 格式**! 而我们 Day4 Proposal 中精心设计的三层解析策略——`json.loads` → `raw_decode` → `tool_calls` 片段提取——三层全部依赖 JSON。XML 输入进去,走到哪一层都是 `JSONDecodeError`。 ### 数据不会说谎 | 指标 | prompt_json (prompt注入) | builtin (内置传参) | | --------------- | ----------------------------------- | --------------------------- | | **输出格式** | `{"content":"","tool_calls":[...]}` | `` | | **6样例成功率** | 83.3% (5/6) | 0.0% (0/6) | | **失败原因** | 1例互斥失败 | 6例全部 JSONDecodeError | | **平均延迟** | 4040.9ms | 3173.4ms | | **Token 消耗** | tools_schema ~500 token | 0 token | 对比直观: ```text title="成功率对比 (ASCII bar)" 成功率: prompt_json ████████████████████░░░░ 83.3% builtin ░░░░░░░░░░░░░░░░░░░░░░░░ 0.0% ``` ### 结论 > **prompt 注入方式在当前 JSON 解析架构下更可靠。内置传参虽然理论上更"优雅",且节省 ~500 token,但 Qwen3.5-4B 原生的工具调用输出是 XML 格式,与 JSON 解析器完全不兼容。要使用 builtin,需要单独实现 XML 解析器并适配整个三层解析链路。** 这个结论的价值在于:它用数据说明了**为什么 prompt 工程在小模型场景下比"原生能力"更可控**。不是"prompt 注入是野路子",而是——当你不能控制模型的输出格式时,控制 prompt 就是你唯一能做的事。 > 说实话,看到 builtin 输出 XML 的那一刻,我先是愣了一下,然后忍不住笑了。原以为 builtin 是"正道"、prompt 注入是"野路子",结果正道走不通,野路子反而稳如老狗。这也是这次实训给我的感悟:**不要迷信"原生能力",在小模型上,你能掌控的东西才是你真正拥有的东西。** --- ## 五、批量成功率与延迟统计 ### 测试框架 为了量化评估,我写了一个 `b4_batch_benchmark.py`,构造 **6 个不同场景**的测试样例,每个样例跑两种传参方式,统计成功率和延迟: | 样例 ID | 场景 | 预期工具 | 用户输入摘要 | | --------------------- | ---------------- | -------------------------------------------------- | --------------------------- | | `case_file_read` | 读取本地文件 | `file_reader` | "阅读 docs/agent_intro.txt" | | `case_calculator` | 数学计算 | `calculator` | "计算 3.14 \* 5 - 2.5" | | `case_file_search` | 文件搜索 | `local_file_search` | "搜索包含 Agent 的文件" | | `case_multi_tool` | 并发多工具 | `file_reader` + `calculator` + `local_file_search` | "帮我做三件事" | | `case_direct_answer` | 直接回答不调工具 | 无 | "什么是 Agent?" | | `case_table_analyzer` | 表格分析 | `table_analyzer` | "分析 data/sample.csv" | 覆盖了五种工具类型 + 直接回答 + 并发场景,基本涵盖了 Agent 系统常见的工具调用形态。 ### prompt_json 模式结果:5/6 通过(83.3%) ```text title="b4_batch_benchmark.py 输出 — prompt_json 模式" success: 5, error: 1, rate: 83.3%, avg_latency: 4040.9ms case_file_read: [OK] success tool_calls=1 1574.5ms case_calculator: [FAIL] error tool_calls=0 9222.5ms ← content+tool_calls 互斥失败 case_file_search: [OK] success tool_calls=1 3592.3ms case_multi_tool: [OK] success tool_calls=3 7361.7ms case_direct_answer: [OK] success tool_calls=0 769.7ms case_table_analyzer: [OK] success tool_calls=1 1724.8ms ``` $$rate = \frac{n_{success}}{n_{total}} = \frac{5}{6} \times 100\% \approx 83.3\%$$ $$\bar{t}_{latency} = \frac{1}{n}\sum_{i=1}^{n} t_i = \frac{1574.5 + 9222.5 + 3592.3 + 7361.7 + 769.7 + 1724.8}{6} \approx 4040.9\text{ms}$$ ### 唯一失败 case 的根因分析 `case_calculator` 的表达式 `3.14 * 5 - 2.5` 比较长,模型同时输出了 `content` 和 `tool_calls`,触发了 Day4 Proposal 中设计的**四层互斥约束**: ```json title="case_calculator 失败输出(示意)" { "content": "我来帮你计算 3.14 * 5 - 2.5 = 13.2", // ← 不应该出现 "tool_calls": [{"id": "call_001", "name": "calculator", ...}] // ← 也不应该为空 } ``` 回顾 Day4 Proposal 中的互斥规则: > content 和 tool_calls **二者必有其一,不可同时存在或同时为空**。 小模型在处理长表达式时容易"犹豫"——既想自己算(输出 content),又想调用工具(输出 tool_calls),结果两边都做了,触发了互斥校验。 解决方案有两种思路: 1. **更严格的 prompt 约束**:在 `envelope_reminder` 中强调"content 和 tool_calls 互斥" 2. **重试机制**:检测到互斥失败后自动 retry,在重试 prompt 中加入更强的约束 ### builtin 模式结果:0/6 全灭 ```text title="b4_batch_benchmark.py 输出 — builtin 模式" success: 0, error: 6, rate: 0.0%, avg_latency: 3173.4ms 全部 JSONDecodeError: Expecting value ``` 不需要逐样例分析了——6 个样例全部因为 Qwen 原生 XML 输出格式与 JSON 解析器不兼容而失败。不过值得注意的是 builtin 模式平均延迟更低(3173.4ms vs 4040.9ms),因为不需要解析 500 token 的 tools_schema,prompt 更短推理更快——只是解析全挂了,再快也没用。 > 说实话,如果没有这个批量测试脚本,我可能永远不会发现 `case_calculator` 那个互斥失败的 corner case——它是那种"跑一次没事、跑十次才碰到一次"的间歇性问题。**批量测试是唯一的真相来源**。 --- ## 总结 1. **Prompt 工程在小模型上比"原生能力"重要得多**。builtin 理论上更优雅,但在 JSON 格式约束下,prompt 注入在实际中完胜。你控制不了模型,但你能控制 prompt——**把能控制的做到极致**。 2. **多 tool_calls 并发不是什么黑魔法**——只需要改三行 prompt 模板,4B 模型就能从"单步调用"进化到"三并发"。不是模型能力不够,而是你给它的自由度决定了它的行为边界。 3. **Plan-and-Execute 的精髓在于两阶段 prompt 设计**:计划生成阶段给自由度和结构化引导,步骤执行阶段给约束和状态告知。两个阶段的目标不同,prompt 必须不同。 4. **模型切换本质是配置管理**。Day4 Proposal 中的 `_MODEL_CACHE` 天然支持多模型,只需要加一个 name resolver——好的架构让增量改动聚焦在最薄的接口层。 5. **批量测试是唯一的真相来源**。不跑 6 个样例就发现不了 calculator 的互斥失败 corner case,也发现不了 builtin 的 XML 输出问题。数据不骗人。 ### Commit 记录 | Commit | 说明 | 日期 | | --------- | ---------------------------------------------------------- | ---------- | | `8b69551` | feat(B4): 支持单轮多个tool_calls与多个ToolMessage | 2026-06-30 | | `3507c95` | feat(B4): 新增3-5并发tool_calls极限测试数据 | 2026-06-30 | | `af02640` | feat(B4): Plan-and-Execute 模式支持 | 2026-06-30 | | `a9e7dd6` | feat(B4): 模型切换支持 | 2026-06-30 | | `343ca84` | feat(B4): tools_schema传参方式对比(prompt注入 vs 内置传参) | 2026-06-30 | | `4420d86` | feat(B4): 批量测试脚本 + 工具调用成功率与token统计 | 2026-06-30 | --- ## d3-force-3d 构建时预计算 3D 网络布局,减少客户端渲染压力 URL: https://xingwangzhe.fun/posts/d3-force-precompute-3d/ License: CC-BY-NC-SA-4.0 ## 前言 [友链图谱](https://links.needhelp.icu/) 是一个汇聚了 **1600+ 节点**、**2200+ 连接**的 3D 友链网络可视化项目。数据源是 `links/*.yml` 文件,渲染层基于 `3d-force-graph`(Three.js + d3-force-3d)。 在[上一篇文章](https://xingwangzhe.fun/posts/2d3d-graph-v2)中,我介绍了友链图谱从 2D 升级到 3D 的整体架构——包括 3D 力导布局的数学原理、节点渲染和高亮系统。但那时的方案是:**构建时只输出数据,客户端自行跑力导仿真**。 这个方案有两个痛点: | 痛点 | 表现 | | ---------- | ------------------------------------------------------- | | 加载慢 | 每次刷新都要从 [-5,5] 随机位置重新跑动画 | | hover 卡顿 | 每次悬停触发 `Graph.refresh()` → 遍历 1600 节点计算颜色 | | 构建浪费 | 构建 2 秒完成,但客户端要跑 5-10 秒动画 | > 声明:本文在撰写过程中使用了 AI 工具辅助分析技术资料、整理踩坑经验及润色排版。 ## 思路:构建时预计算位置 「既然 `d3-force-3d` 在客户端跑,那在构建时(Node.js)也跑一遍不就提前拿到位置了吗?」 [d3-force-3d](https://github.com/vasturiano/d3-force-3d) 官方文档明确支持这种做法: > `simulation.tick()` — "This method can be used in conjunction with `simulation.stop` to **compute a static force layout**." 于是我在 Astro API 端点 `graph.json.ts` 中加入了构建时预计算: ```ts import { forceSimulation, forceLink, forceManyBody, forceCenter } from "d3-force-3d"; const sim = forceSimulation(nodes, 3) // 明确 3 维! .force( "link", forceLink(links) .id((d) => d.id) .distance(30), ) .force("charge", forceManyBody().strength(-60)) .force("center", forceCenter(0, 0, 0)) // 默认 strength=1 .alphaDecay(0.02) .velocityDecay(0.3); for (let i = 0; i < 300; i++) sim.tick(); sim.stop(); ``` 客户端改成加载预计算位置,直接冻结: ```ts .cooldownTicks(0) .cooldownTime(0) ``` 然而……事情没那么简单。 ## 踩坑 1:平面坍塌 跑出来的效果: ``` X: std=635 范围 [-1780, 1594] Y: std=523 范围 [-2107, 1317] Z: std=141 范围 [-200, 200] ← 被压扁了! 3D ratio = 0.223 ⚠️ 扁平 ``` Z 轴被压到只有 ±200,不管怎么调参数——`distance=30` 还是 `40`,`charge=-60` 还是 `-120`,`alphaDecay=0.02` 还是 `0.005`——Z 永远塌在初始半径以内。 **原因**:`forceCenter(0,0,0)` 的默认强度是 **1.0**,而 `charge` 只有 **-60**、`link` 只有 **distance=30**。center 力比其他力大了两个数量级,强行把所有节点往原点拉,Z 轴首当其冲被消解。 有趣的是,客户端渲染时同样的参数看起来却是"3D 网络"——因为客户端跑到 200 tick 就停了(`cooldownTicks`),还没收敛到平面,停在了一个好看的**暂态**。但构建时要的是一劳永逸,不能依赖"没跑完"。 ## 踩坑 2:花哨尝试一一失败 | 方案 | 结果 | | ------------------------------------------ | --------------------------- | | `forceRadial(800).strength(0.05)` | 强制球壳,不是网络 | | 自定义 `zBias` 力(随机 Z 扰动) | 被 alpha 衰减消解,Z 还是塌 | | 去掉 `forceCenter`,只用 `forceX`/`forceY` | 中心节点子节点形成圆盘 | | `forceCenter.strength(0.3)` | 0.651,不够 | 每次调整都离目标差一点,走了很多弯路。 ## 最终方案:三轴等强 + 弱居中 关键洞察是:**不要对抗 forceCenter,而是把它减弱到和其他力一个量级**。 ```ts const sim = forceSimulation(nodes, 3) .force( "link", forceLink(links) .id((d) => d.id) .distance(40), ) .force("charge", forceManyBody().strength(-120)) .force("center", forceCenter(0, 0, 0).strength(0.02)) // ← 关键! .alphaDecay(0.01) .velocityDecay(0.4); for (let i = 0; i < 500; i++) sim.tick(); sim.stop(); ``` | 参数 | 值 | 作用 | | ----------------- | :------: | :------------------------: | | `link.distance` | 40 | 连接节点间距 | | `charge` | -120 | 排斥力(比默认强一倍) | | `center.strength` | **0.02** | 极弱居中,仅防漂移 | | `alphaDecay` | 0.01 | 慢冷却,充分收敛 | | `velocityDecay` | 0.4 | 与 3d-force-graph 默认对齐 | | tick | 500 | 完全跑完 | **为什么 0.02 有效?** `strength=0.02` 意味着每 tick 所有节点只向中心移动 2% 的距离。这刚好抵消整体漂移,但完全不足以压倒 link 和 charge 力。三种力在同一个量级上互相平衡,自然形成 3D 散布。 结果: ``` X: std=478 范围 [-1288, 1265] Y: std=312 范围 [-640, 1315] Z: std=357 范围 [-1076, 802] 3D ratio = 0.747 ✅ 良好的 3D 网络 ``` 三轴散布均衡,没有平面、没有球壳、没有圆盘,是一个真正的**体积网络**(volumetric network)。 ## 客户端配置 客户端直接加载预计算位置,不再跑额外仿真: ```ts .graphData(graphData) .warmupTicks(0) .cooldownTicks(0) .cooldownTime(0) .d3AlphaDecay(0.02) .d3VelocityDecay(0.3); ``` 注意 `link` 和 `charge` 力**不能禁用**(`d3Force(null)`),否则 3d-force-graph 的连线渲染会丢失。只要 `cooldownTicks(0)`,仿真在一 tick 后立即冻结,位置不会被挪动。 ## 效果对比 | 指标 | 客户端渲染 | 构建时预计算 | | ---------- | :-----------------: | :-----------------------------: | | 页面加载 | 等 5-10 秒动画 | **即开即用** | | 构建时间 | 2 秒 | **20 秒**(一次编译,永久使用) | | hover 卡顿 | 有(Graph.refresh) | 无(已优化) | | 3D 散布 | 暂态,依赖 timing | **稳定** | | 布局可复现 | ❌ 每次随机 | ✅ 固定(只要 seed 不变) | ## 总结 构建时预计算 3D 力导布局**完全可行**,关键不是"要不要用 forceCenter",而是**把 forceCenter 的强度降到和其他力一个量级**。 | center.strength | 结果 | | :-------------: | :--------------------------: | | 1.0 | 平面(center 主宰) | | 0.0 | 漂移(无约束) | | **0.02** | **3D 网络 ✅(三种力平衡)** | 项目仓库:[xingwangzhe/FriendLinks](https://github.com/xingwangzhe/FriendLinks) — 欢迎 star 和 PR! 友链图谱在线体验:[https://links.needhelp.icu/](https://links.needhelp.icu/) --- ## 友链图谱 2.0 - 3D 更美观的可视化 URL: https://xingwangzhe.fun/posts/2d3d-graph-v2/ License: CC-BY-NC-SA-4.0 > 声明:本文在撰写过程中使用了 AI 工具辅助润色与排版。 ## 前言 还记得我之前写的 [友链图谱 - 汇聚千丝万缕的联系](https://xingwangzhe.fun/posts/友链图谱-汇聚千丝万缕的联系) 吗?那时候还是 2D 平面的展示方式,虽然也能看出关系,但总觉得少了点什么。 这次我彻底重构了可视化引擎,从 2D 升级到 **3D 球状网络**!基于 Three.js 和 3d-force-graph,让整个友链网络在三维空间中旋转、缩放,视觉效果直接拉满。 ## 友链图谱 2.0 友链图谱网址: [https://links.needhelp.icu/](https://links.needhelp.icu/) Github: [https://github.com/xingwangzhe/FriendLinks](https://github.com/xingwangzhe/FriendLinks) ### 3D 球状网络 ![3D 球状网络概览](/links/2.0-overview.webp) 友链关系以 **3D 球状网络** 呈现,基于 Three.js 渲染: | 特性 | 说明 | | ------------- | -------------------------------- | | 鼠标拖拽 | 可旋转视角 | | 滚轮缩放 | 自由探索网络 | | 方向粒子流动 | 连线带粒子流动,直观展示友链指向 | | 节点大小 | 反映链接数量(度数越大节点越大) | | 暗色/亮色主题 | 自动切换 | ### 聚焦与高亮效果 ![聚焦与高亮效果展示](/links/2.0-focus.webp) 右键点击任意节点,会触发 **聚焦效果**: | 效果 | 说明 | | ---------------- | ---------------------------------------- | | 节点放大 1.5 倍 | 聚焦节点半径放大,一眼就能找到 | | 节点颜色调亮 60% | 高亮显示,周围节点保持原色 | | 连线金色高亮 | 相连连线变为亮金色(2.5 倍粗、高不透明) | | 相机自动移动 | 将节点移动到视野中心 | 从宏观宇宙中看,聚焦节点就像一颗 **恒星**,金色连线像光束一样指向它,非常醒目。 ### 搜索与定位 ![搜索功能](/links/2.0-search.webp) | 交互 | 说明 | | ------------ | ----------------------------- | | 顶部搜索框 | 支持模糊搜索站点名、域名 | | 左键点击节点 | 在新标签页打开对应网站 | | 右键点击节点 | 聚焦该节点(相机拉近 + 高亮) | | 悬停节点 | 显示站点名称、描述和链接 | ### URL 自动聚焦 如果你已经在网络中,可以通过查询参数来自动聚焦并高亮指定站点节点: | 匹配方式 | 示例 URL | | -------- | ----------------------------------------------------------- | | 域名匹配 | `https://links.needhelp.icu/?local=xingwangzhe.fun` | | 完整 URL | `https://links.needhelp.icu/?local=https://xingwangzhe.fun` | ## 技术实现 ### 3D 力导布局 ```mermaid flowchart TD A[links/*.yml 站点数据] --> B[解析节点与友链] B --> C[nodes: 站点 + 外部友链] B --> D[links: 互链 / 单向友链] C --> E[初始球面位置] E --> F[forceSimulation 3D] D --> F F --> G[forceLink 弹簧力] F --> H[forceManyBody 库仑斥力] F --> I[forceCenter 质心归零] F --> J{迭代 300 ticks} J --> K[alpha 冷却] J --> L[velocity 衰减] K --> M[更新位置] L --> M M --> J J --> N[输出 graph.json] ``` 使用 **d3-force-3d** 在构建时预计算节点位置,客户端直接加载预计算好的坐标,开箱即用,无需等待布局收敛。 各步骤的数学细节: | 步骤 | 输入 | 公式 | | ------------- | ------------------ | ----------------------------------------------------------------------------- | | 初始位置 | 节点索引 $i$ | $x = \sin(i) \cdot 200,\ y = \cos(1.3i) \cdot 200,\ z = \sin(0.7i) \cdot 200$ | | Link 力 | 节点对距离 $d$ | $F = \frac{\|d\| - 40}{\|d\|} \cdot \alpha \cdot S$ | | Many-body 力 | 节点对距离 $d$ | $F = \dfrac{-60 \cdot \alpha}{\|d\|^2}$,Barnes-Hut 近似加速 | | Center 力 | 全体质心 $\bar{p}$ | 反向平移使 $\bar{p} \to (0, 0, 0)$ | | alpha 冷却 | 当前 $\alpha$ | $\alpha \gets 0.98\alpha$(对应 `.alphaDecay(0.02)`) | | velocity 衰减 | 当前速度 $v$ | $v \gets 0.7v$(对应 `.velocityDecay(0.3)`) | > 研究本地仓库 `src/pages/graph.json.ts` 时发现:当前调用 `forceSimulation(simNodes)` 没有传入维度参数,`d3-force-3d` 默认是 **2D** 仿真。若要真正在三维空间计算 `z` 坐标,需要改为 `forceSimulation(simNodes, 3)`。 ### 节点渲染 | 属性 | 值 | | -------- | ------------------------------------------------- | | 几何体 | SphereGeometry(球体,8 段细分) | | 材质 | MeshLambertMaterial(Lambert 漫反射,有光照阴影) | | 不透明度 | 1.0(完全不透明) | | 尺寸 | 基于节点度数动态计算 | ### 高亮系统 | 状态 | 节点颜色 | 连线颜色 | 连线宽度 | | ------ | -------- | ------------- | -------- | | 聚焦 | 调亮 60% | 亮金色 0.95 | 2.5 | | 悬停 | 调亮 40% | 白色/灰色 0.5 | 0.4 | | 高亮组 | 调亮 20% | 淡色 0.3 | 0.4 | | 默认 | 原色 | 淡色 0.08 | 0.4 | ## 加入网络 和之前一样简单,fork 仓库,在 `links` 文件夹下用你的域名作为文件名创建 `yml` 文件: ```yml site: name: 我的博客 description: 分享编程和技术相关的文章 url: https://example.com friends: - name: 编程小站 url: https://codehub.example.com - name: 技术前沿 url: https://techfrontier.example.com ``` ## 写在最后 从 2D 到 3D,从平面到立体,友链图谱的升级不仅仅是视觉上的提升,更是对 **Web 本意** 的回归——让信息在空间中有机地连接在一起。 欢迎访问 [友链图谱 2.0](https://links.needhelp.icu/),找到你的博客在宇宙中的位置! --- ## 告别 Pagefind,用 Orama 实现静态博客的全文搜索 URL: https://xingwangzhe.fun/posts/orama-search-replace-pagefind/ License: CC-BY-NC-SA-4.0 > **本文部分内容(Pagefind vs Orama 原理分析)存在 AI 辅助生成** 最近我把博客的搜索从 `astro-pagefind` 换成了 Orama,过程踩了不少坑。本文记录一下完整的方案和踩坑经验。 ## 为什么换掉 Pagefind Pagefind 本身是个优秀的静态搜索方案——构建时生成碎片化索引,按需加载。但用了这么长时间,几个问题越来越不能忍: **Dev 模式下搜索不可用**。这是最大的痛点。Pagefind 只在 `astro build` 时生成索引,`astro dev` 下搜索框打了字永远返回空结果。每次想在本地调搜索样式,都得先 build 一遍,非常影响效率。 **中文分词不够好**。Pagefind 对中文有一定支持,但实际体验中分词精度不足。比如搜"公安",有很多时候搜不到。 **需要 HTML 标注**。Pagefind 依赖 `data-pagefind-body` 之类的属性来控制索引范围,对于高度自定义的 Astro 组件来说,多了一层心智负担。 ## 选型:为什么是 Orama [Orama](https://docs.orama.com/) 是一个运行在浏览器里的全文搜索引擎,核心包 `@orama/orama` 。几个关键能力打动了我: | 特性 | 说明 | | ------------------------------- | ------------------------------------------------------------------------------------------ | | **纯客户端运行** | 索引是一个 JSON 文件,浏览器 fetch 后全部在本地搜索,零后端依赖。 | | **30+ 语言支持** | `@orama/tokenizers/mandarin` 专门做中文分词,配合 `@orama/stopwords/mandarin` 停用词过滤。 | | **从数据源直接建索引** | 不需要像 Pagefind 那样抓取渲染后的 HTML,直接从 Astro 的 Content Collections 拿数据。 | | **搜索权重可配置** | `boost` 参数让标题匹配高于正文匹配,搜索结果更符合直觉。 | | **`@orama/highlight` 精准高亮** | 能定位关键词在全文中的位置,摘要自动居中。 | ## 底层对比:Pagefind 的分片索引 vs Orama 的 BM25 全文检索引擎 介绍完选型理由,来深入扒一扒这两种搜索方案在分词、索引和评分上的根本差异。以下所有分析都基于两边的**公开源码**——Pagefind 的 Rust 核心([GitHub](https://github.com/CloudCannon/pagefind))和 Orama 的 TypeScript 实现([GitHub](https://github.com/oramasearch/orama))。 说实话,不看源码之前我也有很多想当然的理解,看了之后才发现事实跟我想的差别不小。 --- ### 一个重要的前提:两者本身都不带中文分词 先说清楚一个很多人(包括之前我)容易忽略的事实: **Pagefind 和 Orama 本质上都是搜索引擎内核**——它们负责索引的构建、存储、搜索和打分,但**中文文本的分词本身不是它们的职责**。中文分词需要额外接入专门的分词包。 | 方案 | 中文支持方式 | | -------- | ------------------------------------------------------------------ | | Pagefind | 编译期开启 `extended` feature 来启用 `charabia`(底层 `jieba-rs`) | | Orama | 额外安装 `@orama/tokenizers/mandarin` 包(底层 `Intl.Segmenter`) | 没有这些额外包,两者对中文的处理方式完全一致:按空白符和标点拆分。这对英文没问题,对中文就是**灾难**。 --- ### Pagefind 的分词逻辑 **Pagefind 默认的"分词"** 涉及两个文件。`pagefind/src/fossick/splitting.rs` 里的 `get_indexable_words` 函数处理单个词单元的归一化: | # | 处理步骤 | 说明 | | --- | ---------------- | ------------------------------------------------------------------------------------------------------------------- | | 1 | **字母数字过滤** | 遍历每个字符,只保留 `is_alphanumeric()` 为 true 的(中文汉字满足这个条件) | | 2 | **小写化** | 对非 ASCII 大写字母调用 `to_lowercase()` | | 3 | **词干提取** | 如果传入了 stemmer(语种相关),对去变音符号后的词做词干提取 | | 4 | **复合词拆分** | 通过 `convert_case` crate 的 `Case::Lower` 拆解驼峰、蛇形、连字符命名(如 `camelCase`、`snake_case`、`kebab-case`) | 但真正决定"怎么把一段文本切成一个个词"的是**调用方** `pagefind/src/fossick/mod.rs` 里的 `parse_digest` 函数——它先用 `split_whitespace()` 按空白符切分,再把切出来的每个片段交给 `get_indexable_words` 处理。也就是说,Pagefind 默认的"分词"就是 `split_whitespace()`——英文按空格拆,西班牙语按空格拆,中文也按空格拆。一段没有空格的连续中文"全文搜索方案",在索引里就是**一个完整的 token**。 > 我翻源码之前一直以为 Pagefind 对中文做了 bigram(二元组)切分,翻了 `splitting.rs` 之后确认:**没有**。它不做任何字符级 n-gram,也没有 ICU 的中文分词。中文汉字只是通过了 `is_alphanumeric()` 的检查被保留下来,但边界识别全靠空格。 这解释了为什么中文搜索体验差——不是匹配不到,而是匹配方式完全不对。`find_word_extensions` 做的是**前缀匹配**(`key.starts_with(term)`),这意味着: | 场景 | 结果 | 原因 | | -------------------------------- | ------ | ------------------------------------------ | | 搜"公安"匹配"公安局"、"公安机关" | 匹配 | 它们**以"公安"开头** | | 搜"安全"匹配"公共安全系统" | 不匹配 | 索引里是整词"公共安全系统",不以"安全"开头 | | 搜"全系"匹配 | 不匹配 | 前缀匹配不支持**中间**和**尾部**的子串 | 这种匹配机制对于中文来说是完全不可控的:你的输入词必须是目标词的前缀才能命中,而中文恰恰是一种不以空格分界的语言。 **Pagefind 确实有一个可选的 CJK 功能**,在 `Cargo.toml` 中以 `extended` feature 声明: ```toml title="Cargo.toml" [features] extended = ["dep:charabia"] [dependencies] charabia = { version = "0.9.3", optional = true, default-features = false, features = ["chinese","japanese","thai"] } ``` 这个 feature 启用 `charabia` crate 做中日泰分词。而 `charabia` 的 Chinese 分词(见其 [Cargo.toml](https://raw.githubusercontent.com/meilisearch/charabia/main/charabia/Cargo.toml))底层依赖的是 `jieba-rs` v0.8.1,通过 `chinese-segmentation` feature 激活。 **jieba-rs 的分词算法**(源码见 `jieba.rs` 和 `sparse_dag.rs`): | # | 步骤 | 说明 | | --- | -------------- | --------------------------------------------------------------------------------------------------------------- | | 1 | **前缀字典** | 使用 **Double-Array Trie(Cedar)** 存储词频词典,支持非常快的前缀查找 | | 2 | **构建 DAG** | 对输入句子中每个位置,在前缀字典中查找所有可能的词,构建一个有向无环图 | | 3 | **动态规划** | 从句子末尾向前遍历,计算每个位置的最大对数概率路径:`route[i] = max( log(freq(word[i:j])/total) + route[j+1] )` | | 4 | **按路径分割** | 从位置 0 按最优路径向前推进,取出分词结果 | `Pagefind` 调用 `charabia` 时禁用了 HMM(`hmm: false`),所以**无法处理未登录词(OOV)**——词典里没有的词会被切成单个字。这个词典本身来自人民日报等语料库的词频统计,覆盖了绝大多数常见中文词汇。 在 `parse_digest` 函数中,当 `lang` 以 `zh`、`ja` 或 `th` 开头时,会用 `seg.segment_str()`(来自 charabia)对文本做词汇级切分。 但问题是: | 问题 | 说明 | | ----------------------------- | ------------------------------------------------------------------------ | | **不在默认构建中** | 这个 feature 不在默认构建中(default = `["serve"]`) | | **预编译二进制不带 extended** | `astro-pagefind` 等 npm 包下载的是预编译的默认二进制 | | **索引与搜索分词不一致** | 源码明确说浏览器端 WASM 没有同样的分词器,索引时切了词搜索时可能匹配不上 | > "Currently hesitant to run segmentation during indexing that we can't also run during search, since we don't ship a segmenter to the browser." > 翻译:**浏览器端的 WASM 没有同样的分词器**,索引时切了词,搜索时客户端可能无法用同样的方式切分查询词,导致匹配不上。所以这个功能处于一种尴尬的半成品状态。 **Orama 这边同样需要额外包**。`@orama/tokenizers/mandarin` 就是那个专门的中文分词包,它的核心代码只有几十行: ```typescript title="tokenizer.ts" const segmenter = new Intl.Segmenter("zh-CN", { granularity: "word" }); function tokenize(text: string): string[] { const segments = segmenter.segment(text); const tokens: string[] = []; for (const segment of segments) { if (segment.isWordLike) { tokens.push(segment.segment); } } return tokens; } ``` 底层依赖 **浏览器和 Node.js 内置的 `Intl.Segmenter`**,而 `Intl.Segmenter` 背后是 **ICU 的 `DictionaryBasedBreakIterator`**。 ICU 的中文分词走的是 **字典驱动的分词算法**,以最大匹配(Maximum Matching)为基础,配合更复杂的回溯和规则处理: | # | 步骤 | 说明 | | --- | ----------------- | ------------------------------------------------------------ | | 1 | **Trie 词典查找** | 使用 Trie(前缀树)结构的**编译好的字典**,字典大小约 2MB | | 2 | **最长匹配** | 从每个字符位置开始,在字典中查找**最长的匹配词**(最长优先) | | 3 | **词频破平** | 当多个词在相同位置重叠时,用**词频权重**来破平 | | 4 | **单字回退** | 字典里找不到的字符保留为单个字 | 这个算法的分词准确率与 ICU 版本相关,在不额外加载词典的情况下表现稳定,优势在于**不需要额外加载字典文件**——字典已经在浏览器和 Node.js 运行环境里编译好了。 关键优势:**`Intl.Segmenter` 是同一套 API,服务端(Node.js)构建索引时和浏览器查询时分词表现完全一致**。不会出现 Pagefind extended 那种索引端用 charabia/jieba 分词、浏览器端用不了同款分词器的不一致问题。 所以回到核心问题:两种方案对中文的感知能力完全不在一个量级上。 | 场景 | Pagefind(默认) | Pagefind(extended) | Orama(mandarin tokenizer) | | --------------------------------- | ---------------- | ---------------------------------- | -------------------------------- | | "搜索" 是否匹配文章中的"搜索引擎" | 不匹配 | 匹配 | 匹配 | | 索引时如何拆分中文 | 空格/标点分界 | jieba-rs (DAG+DP) | ICU DictionaryBasedBreakIterator | | 搜索时如何拆分中文 | 空格/标点分界 | 空格/标点分界(无 charabia) | ICU DictionaryBasedBreakIterator | | 索引与查询分词一致 | 是 | 不一致 | 是 | | 底层词典 | 无 | Double-Array Trie(Cedar)词频词典 | Trie 编译词典(~2MB,内置) | | 未登录词处理 | N/A | HMM 禁用 | 无 | | 歧义消解 | N/A | DP 全局最优路径 | 字典匹配+词频破平 | --- ### 索引结构:碎片化分片 vs 单文件倒排索引 **Pagefind 的索引组织**(源码见 `pagefind/src/index/mod.rs`): | # | 步骤 | 说明 | | --- | -------------- | ----------------------------------------------------------------------------------------------- | | 1 | **提取词数据** | 解析 HTML 得到每个页面的 `word_data`(词→位置映射)和 `meta_word_data`(元数据字段中的词) | | 2 | **分片** | 所有页面的词表合并后,按**位置总数**(`locs + meta_locs + 1 per page`)切分成多个 chunk | | 3 | **编码词表** | 每个 chunk 包含按字母序排列的词,每个词下是页号(delta 编码)和位置(delta 编码,复合权重编码) | | 4 | **序列化** | chunk 用 CBOR 二进制格式序列化,输出为 `.pf_index` 文件 | | 5 | **元数据索引** | `MetaIndex`(CBOR)记录每个 chunk 的起始词和结束词、分页信息、排序字段、可筛选字段 | 客户端搜索时,流程是: | # | 步骤 | 执行方 | 说明 | | --- | -------------- | ------ | ---------------------------------------------------------------------------- | | 1 | **传递查询词** | JS | 把查询词传给 WASM 的 `request_indexes` 函数 | | 2 | **定位 chunk** | WASM | 根据查询词的前缀,在 `chunks` 元数据中查找需要加载哪些 chunk | | 3 | **加载 chunk** | JS | fetch 对应的 chunk 二进制文件,传入 `load_index_chunk` | | 4 | **搜索打分** | WASM | 所有 chunk 加载完后,调用 `search` 做 BM25 打分、排序 | | 5 | **加载片段** | JS | 用结果中的 `page_hash` 加载对应的**页面片段**(JSON `fragment`)用于生成摘要 | 这个"先查元数据→再按需加载 chunk→再查 chunk 内部的倒排索引"的三层架构,设计初衷是让大型站点不用一次性下载所有索引数据。但对于中小博客,这层间接反而增加了搜索延迟。 **Orama 的索引组织**(源码见 `packages/orama/src/components/index.ts`): | 步骤 | 说明 | | ---------------- | ---------------------------------------------------------------------------- | | **建倒排索引** | 每个 string 类型属性用 Radix Tree(基数树)存储词到文档 ID 的映射 | | **记录评分参数** | 同步记录 `frequencies`、`tokenOccurrences`、`fieldLengths`、`avgFieldLength` | | **序列化** | 调用 `save()` 将完整索引序列化为一个 JSON 对象 | | **客户端加载** | 浏览器 fetch 后用 `load()` 在内存中重建完整的 Radix Tree + 评分参数 | Orama 没有分片,所有数据在一个 JSON 文件里。代价是首次加载需要下载整个索引(~4MB / gzip ~800KB),好处是之后的搜索全是内存操作,零网络往返。 --- ### 搜索评分:两套 BM25 的实现对比 两边的排序算法都基于 BM25,但具体实现和可配置性差别很大。 **Pagefind 的评分**(源码见 `pagefind_web/src/search.rs` 的 `search_term` 和 `calculate_bm25_word_score`): | # | 步骤 | 说明 | | --- | ------------- | ------------------------------------------------------------------------------------------------------- | | 1 | **前缀扩展** | 用 `find_word_extensions` 找到所有以查询词**为前缀**的索引词(如搜"search"匹配"searching"、"searcher") | | 2 | **词长惩罚** | 对每个匹配词计算 `word_length_bonus`——词越长惩罚越大(高斯衰减) | | 3 | **位置合并** | 对每个匹配词组合并**同一个位置的权重**(取最低权重,相同权重则叠加) | | 4 | **BM25 打分** | 对每个匹配词做 BM25 变体计算,带四个可调参数: | | 参数 | 默认值 | 作用 | | ----------------- | ------ | -------------------------------------------- | | `term_similarity` | 1.0 | 控制词长差异的衰减速度 | | `term_saturation` | 1.4 | BM25 的 k1 参数 | | `page_length` | 0.75 | BM25 的 b 参数 | | `term_frequency` | 1.0 | 控制 BM25 的 TF 和原始加权词频之间的插值比例 | Pagefind 的评分特别之处在于它对**元数据字段有独立的加权系统**:代码里 meta_weights 默认给 `title` 字段 5 倍权重,`description`、`image_alt` 等字段也有不同的权重。但这个配置是在 WASM 加载时设死的,不像 Orama 那样可以在搜索请求中动态指定。 **Orama 的评分**(源码见 `packages/orama/src/components/algorithms.ts` 的 `BM25` 函数): BM25 公式实现很标准,和维基百科上的定义一致: ```typescript title="algorithms.ts" export function BM25( tf: number, // 词在文档中的频率 matchingCount: number, // 包含该词的文档数 docsCount: number, // 总文档数 fieldLength: number, // 该文档字段长度 averageFieldLength: number, // 平均字段长度 { k, b, d }: Required, ) { const idf = Math.log(1 + (docsCount - matchingCount + 0.5) / (matchingCount + 0.5)); return (idf * (d + tf * (k + 1))) / (tf + k * (1 - b + (b * fieldLength) / averageFieldLength)); } ``` 参数默认值:`k = 1.2`(词频饱和度)、`b = 0.75`(文档长度归一化)、`d = 0.5` Orama 还在搜索结果排序上多了一层处理:`threshold` 参数控制匹配严格程度——`threshold = 0` 只返回包含**所有**查询词的结果,`threshold = 1` 返回包含**任意**查询词的结果,中间值表示覆盖率阈值。Pagefind 则没有这个机制——只要 `find_word_extensions` 找到了前缀匹配就会返回,没有"所有词必须匹配"的开关。 --- ### 对比汇总 | 维度 | Pagefind | Orama(本方案) | | ---------------------- | --------------------------------------------------------- | --------------------------------- | | **中文分词**(默认) | 只按空格拆分,不做词汇切分 | `Intl.Segmenter` 中文分词 | | **中文分词**(可选项) | `charabia` 词典分词(extended feature)但索引和搜索不一致 | 同一 API 保证一致性 | | **索引结构** | 按位置数分片,CBOR 二进制,按需加载 | 单 JSON 文件,全量加载 | | **索引树** | BTreeMap(有序映射) | Radix Tree(基数树) | | **评分算法** | BM25 变体 + 元数据独立加权 + 前缀匹配 | 标准 BM25 + boost 加权 + 阈值控制 | | **词位置编码** | delta 编码 + 复合权重编码 | 不存储位置(仅 TF) | | **Dev 可用** | 否 | 是 | | **索引来源** | 渲染后 HTML → `data-pagefind-body` | Content Collections 直读 | 补充一句:Orama 其实还有一个 `searchVector` 方法做向量嵌入搜索(用于 AI 语义搜索场景),但**本文的方案用不到**。我们用的是传统的 BM25 全文搜索,没有把文章转成 embedding——别误会。 --- ### Orama 方案的代价 全量加载索引意味着首次搜索前需要下载 ~800KB(gzip)的数据。对于移动端弱网环境,这个体积可能需要优化(比如渐进式加载或者 Service Worker 缓存)。 --- ## 踩坑:官方 Astro 插件不能用 Orama 确实有官方插件 `@orama/plugin-astro`,但装不上。 npm 上最新版 v3.1.18 的 `peerDependencies` 锁在 `astro: ^2.0.4`,而当前项目跑的是 Astro 7。即使 `--force` 强行装上,构建直接崩: ```text title="错误日志" ENOENT: no such file or directory, mkdir '/.../%E6%A1%8C%E9%9D%A2/.../dist/assets' ``` 根因是 Astro 5 改了 `IntegrationRouteData.distURL` 的类型,插件里的 `prepareOramaDb` 还在用旧 API 拿构建目录,路径中的中文被 URL 编码后透传给了 `mkdirSync`。 我去翻了 [GitHub issues](https://github.com/oramasearch/orama) : | Issue | 状态 | | --------------------------------------------------------------------------------------------- | -------------------------- | | [#862](https://github.com/oramasearch/orama/issues/862) — 报告 Astro 5 不兼容 | fix PR #870 **已合并** | | [#882](https://github.com/oramasearch/orama/issues/882) — 要求更新 peer dependency 到 Astro 5 | PR #885 **关闭了但没合并** | 也就是说代码修了一部分、peer dep 根本没更新。Astro 7 下仍然不可用。 结论:**手写 endpoint 是目前唯一的稳的方案**。 ## 实现 ### 1. 构建时生成索引 新建 `src/pages/search-index.json.ts`,作为 Astro 的静态端点: ```typescript title="src/pages/search-index.json.ts" import { create, insertMultiple, save } from "@orama/orama"; import { createTokenizer } from "@orama/tokenizers/mandarin"; import { stopwords as mandarinStopwords } from "@orama/stopwords/mandarin"; import { getCollection } from "astro:content"; import removeMarkdown from "remove-markdown"; const schema = { id: "string", type: "string", title: "string", description: "string", content: "string", url: "string", date: "string", tags: "string[]", } as const; export async function GET() { const [posts, aboutPages, wordEntries] = await Promise.all([ getCollection("posts", ({ data }) => !data.draft), getCollection("about"), getCollection("words", ({ data }) => !data.draft), ]); const documents = []; for (const post of posts) { const body = typeof post.body === "string" ? post.body : ""; documents.push({ id: `post-${post.data.abbrlink}`, type: "post", title: post.data.title, description: post.data.desc, content: removeMarkdown(body), // 清理 markdown 语法 url: `/posts/${post.data.abbrlink}/`, date: post.data.date ?? "", tags: post.data.tags ?? [], }); } // ... about 和 words 类似处理 const db = create({ schema, components: { tokenizer: createTokenizer({ // 中文分词 stopWords: mandarinStopwords, // 中文停用词 }), }, }); insertMultiple(db, documents); const index = save(db); return new Response(JSON.stringify(index), { headers: { "Content-Type": "application/json" }, }); } ``` 关键设计: | 设计要点 | 说明 | | ----------------- | -------------------------------------------------------------------------------------------------------------------------- | | **并行读取** | 用 `Promise.all` 并行读取多 collection | | **清理 markdown** | `remove-markdown` 清理正文中的 `##`、`_text_`、`[link]()` 等语法 | | **中文分词** | `createTokenizer({ stopWords })` 配置中文分词和停用词 | | **原始正文来源** | `post.body` 来自 Astro content collection 的 `retainBody: true` 配置(`content.config.ts` 中设置),是原始 markdown 字符串 | ### 2. 客户端搜索组件 改造 `src/components/stalux/common/search.astro`,保留原有模态框壳子,换掉内部 Pagefind 组件: ```html title="search.astro" ``` 服务端和客户端**同时配置相同的分词器**是必须的,否则索引时和搜索时分词不一致会导致匹配失败。 ### 3. 配置清理 `astro.config.mjs` 中移除 Pagefind 集成;`package.json` 中: ```diff lang="json" title="package.json" - "astro-pagefind": "^2.0.0" + "@orama/orama": "^3.1.18" + "@orama/tokenizers": "^3.1.18" + "@orama/stopwords": "^3.1.18" + "@orama/highlight": "^0.1.9" + "remove-markdown": "^0.6.4" ``` ## Dev 模式下能搜索,这才是核心 `.json.ts` 端点被 Astro 作为 API 路由处理——在 `astro dev` 和 `astro build` 下都能正常响应。每次请求时动态从 Content Collections 读取并构建索引。 这意味着: | 场景 | 体验 | | ------------ | ------------------------------------------ | | **搜索** | 本地开发时打开搜索,结果立刻出来 | | **调参** | 调整分词参数后刷新即生效,不需要重新 build | | **调试样式** | 调试高亮样式、摘要长度时所见即所得 | 这是整个方案相比 Pagefind 最大的优势。 ## 效果对比 | 特性 | Pagefind | Orama (本方案) | | ------------ | -------------------- | ----------------------------- | | Dev 模式搜索 | 不可用 | 可用 | | 中文分词 | 基础支持 | mandarin tokenizer | | 高亮精度 | 基本 | `trim()` 自动居中 | | 索引来源 | 渲染后 HTML | Content Collections 直接读 | | 搜索权重 | 有限 | `boost` 自定义 | | 作用域标注 | `data-pagefind-body` | 不需要 | | 索引输出 | 碎片化文件 | 单个 JSON (~4MB, gzip ~800KB) | ## 参考链接 1. [pagefind/src/fossick/mod.rs](https://github.com/CloudCannon/pagefind/blob/main/pagefind/src/fossick/mod.rs) 2. [ICU DictionaryBasedBreakIterator](https://unicode-org.github.io/icu-docs/apidoc/released/icu4c/classBreakIterator.html) 3. [packages/orama/src/methods/search-fulltext.ts](https://github.com/oramasearch/orama) 4. [remove-markdown](https://www.npmjs.com/package/remove-markdown) ## 小结 搜索是博客体验的最后一公里。一个能在 dev 阶段就调试的搜索系统,不仅能提升读者体验,也让开发流程顺畅得多。Pagefind 是个好工具,但 Orama 在灵活性、中文支持和开发体验上明显更胜一筹。 如果你也用 Astro,不妨试试这个方案。代码都在 [stalux 主题仓库](https://github.com/xingwangzhe/stalux)里。 --- ## 人工智能实训Day4:Agent智能体系统Proposal——B4 LLM决策模块 URL: https://xingwangzhe.fun/posts/ai-training-agent-day4-proposal/ License: CC-BY-NC-SA-4.0 前置声明:**本图文存在AI辅助整理** 说实话,前三天的实训从搭环境到做 SFT/DPO 对齐,再到上手 Agent 工具调用,算是把整个大模型应用链路走了一遍。今天到了**重头戏**:我们要为一个完整的 Agent 智能体系统撰写 Proposal,而我负责的模块是 **B4 LLM 决策模块**——一个需要让 4B 小模型稳定执行工具调用决策的核心模块。 ![Agent 智能体系统 Proposal 封面](/AI/封面-day4.webp) ## 个人模块 Proposal ## 1. 基本信息 小组名称 / 项目名称:Agent智能体系统(方向B) 选择的模块名称:B4 Agent LLM决策模块 合并的系统名称:Agent智能体系统 --- ## 2. 项目整体背景与个人模块定位 ### 2.1 项目整体目标 本项目面向个人本地知识处理与智能文档分析场景,尝试解决在无外部API依赖下,利用本地4B参数小模型实现稳定工具调用和自主任务执行的问题。整体系统包括B1 Agent Runtime(运行时编排)、B2 Skill(技能函数层)、B3 Tool Layer(工具调用层)、B4 LLM决策模块(本模块)、B5 Memory(记忆系统)等模块,最终希望实现用户在本地环境中通过自然语言指令完成文件读取、数据分析、信息检索等复杂任务的完整Agent能力。 ### 2.2 个人模块在整体系统中的定位 ```mermaid graph TD User["用户"] --> B1["B1 Runtime 运行时管理"] B1 --> B5["B5 Memory 记忆系统"] B5 --> B1 B1 --> B4["B4 LLM决策模块 (本模块)"] B4 --> B1 B1 --> B3["B3 Tool Layer 工具调用层"] B3 --> B2["B2 Skill 技能函数层"] B2 --> B3 B3 --> B1 B1 --> User ``` 该模块属于系统中的**认知决策层**,是系统中唯一与大语言模型直接交互的模块,承担着将自然语言的不确定性转化为结构化确定性输出的核心职责。系统整体遵循ReAct(Reasoning + Acting)范式[2]:B1作为编排者接收用户输入并维护消息序列,管理Agent Loop的状态转换和工具执行分发;B5负责记忆的存储、检索和注入,使Agent具备跨会话的上下文感知能力[5];B4调用本地部署的Qwen3.5-4B模型[1]进行推理决策,分析对话上下文和可用工具描述,生成明确的下一步行动指令;B3作为Skill与LLM之间的桥接层,将B4的决策转化为实际工具执行并调用B2的Skill函数;工具结果回传后B4再次决策,直至生成最终回答。整个系统的核心设计哲学是"模型驱动、模块解耦"——B4作为唯一接触LLM的模块,其设计质量直接决定了整个Agent系统的可用性。 该模块接收以下输入:来自B1的messages(包含SystemMessage、HumanMessage、AIMessage、ToolMessage的完整对话历史序列)、来自B3(经B1中转)的tools_schema(OpenAI function calling格式的工具描述列表)、以及model.yaml(模型配置,包含模型路径、精度、解码策略等参数)。 该模块输出标准化的AIMessage(包含content或tool_calls之一,但不会同时存在或同时为空),以及status("success"或"error")和error(错误详情字典,含type和message字段)。当status为"error"时,ai_message内容为"模型输出解析失败,无法生成有效工具调用或最终回答。" 该模块为整体系统提供LLM推理和决策能力,决定"是否调用工具""调用哪个工具""传递什么参数""是否直接回答",是连接自然语言理解与结构化工具执行的核心桥梁。没有B4,系统有工具、有记忆、有运行框架,但缺乏做出判断的"大脑",只能做简单问答,无法完成从用户目标到工具执行的闭环。B4的设计质量直接决定了整个Agent系统的可用性——如果B4无法稳定输出合法的tool_calls,B3就无法执行工具,B1的Agent Loop就会陷入停滞。 --- ## 3. 模块要解决的具体问题 本模块主要解决**本地部署的4B参数小模型如何稳定执行工具调用决策**的问题。由于商用大模型(GPT-4/Claude等)通过专用API实现原生function calling[4],而本地Qwen3.5-4B[1]仅有40亿参数,直接使用通用文本生成接口会导致输出格式不稳定、工具调用成功率低。因此,本模块需要实现一套完整的prompt工程、输出解析和错误处理机制,弥合小模型能力与工具调用需求之间的差距。 该模块面对的具体任务是:将对话消息序列与工具描述编码为模型输入,驱动模型在"直接回答用户"和"请求调用工具"之间做出明确决策,并将模型生成的纯文本输出解析为结构化的AIMessage。原始问题中有四个核心难点: | 问题 | 说明 | | -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | **小模型工具调用能力限制** | 4B参数模型在理解复杂工具描述、判断调用时机、构造正确参数方面存在瓶颈,面临意图识别(判断用户请求是否需要工具)、工具选择(多个相似工具中选最合适的)、参数构造(从上下文提取正确的参数值)三重困难。商用大模型通过专用function calling API和大量微调数据解决了这些问题,但本地小模型缺乏这些优化 | | **结构化JSON输出的鲁棒性** | 模型输出常见三类错误:markdown代码块包裹(`json...`)、JSON前后附加解释文字、CoT思考标签(`<\|thinking\|>`)破坏JSON语法,导致下游解析失败。这些问题在4B模型上尤为突出,因为小模型的指令遵循能力较弱,容易偏离预设的输出格式 | | **Prompt工程与4096 token上下文的矛盾** | 工具schema(500-800 token)+ 格式说明(300-400 token)大量消耗上下文窗口,挤压对话历史空间,长对话中前几轮上下文可能被截断。这导致Agent能处理的对话轮数和工具复杂度受到严格限制 | | **content与tool_calls的互斥决策** | 模型必须在"直接回答"(schema A:content非空+tool_calls为空)和"请求调用工具"(schema B:content为空+tool_calls非空)之间明确二选一,模糊状态(如两者都非空或两者都为空)会导致B1控制流混乱,无法判断下一步走向 | 如果没有本模块,整体系统将失去决策能力——有工具、有记忆、有运行框架,但缺乏做出判断的"大脑",无法完成从用户目标到工具执行的闭环,最终只能做简单问答。 本模块的预期目标是:实现稳定的tool_calls生成与解析,支持至少一轮"LLM→Tool→LLM"闭环,在prompt_json模式下工具调用格式合规率达到可用水平,并为B1提供明确的status字段用于状态机转换。验收时要求B4和完整演示必须使用prompt_json模式完成真实模型演示,mock仅作为无GPU或模块联调时的调试模式。 --- ## 4. 技术方案与实现路径 ### 4.1 技术选型 | 类型 | 名称 | 用途 | 选择原因 | | ------------ | --------------------------- | -------------- | ------------------------------------------------------------------------------------------- | | 编程语言 | Python 3.10 | 模块开发 | 类型提示支持完善(`str \| None`语法)、成熟的科学计算生态、与实验室框架统一 | | 基础模型 | Qwen3.5-4B-Instruct | LLM推理 | 4B参数,中文能力强,支持工具调用场景,本地部署路径`/root/siton-pub/assignment_B/Qwen3.5-4B` | | 推理框架 | HuggingFace Transformers[7] | 模型加载与推理 | `AutoModelForCausalLM` + `AutoTokenizer`,`apply_chat_template`自动处理ChatML格式 | | 深度学习后端 | PyTorch 2.x | 模型推理后端 | Transformers依赖,支持bfloat16精度和device_map自动分配 | | 配置解析 | PyYAML | model.yaml解析 | 模型参数与代码逻辑分离,运维人员可调整行为而无需改代码 | | 数据序列化 | json(标准库) | 输入输出标准化 | messages、tools_schema和输出文件均使用JSON,与B1/B3接口统一 | | 数值精度 | bfloat16 | 模型权重存储 | 相比float16避免梯度下溢,相比float32节省50%显存 | | 设备映射 | device_map: auto | GPU/CPU分配 | 自动分配模型层到可用设备,适配不同显存配置 | 核心配置参数(来自model.yaml): | 配置项 | 值 | 说明 | | ----------------- | ----------- | --------------------------------------------------- | | torch_dtype | bfloat16 | 稳定性与显存效率平衡 | | local_files_only | true | 完全离线运行,不从HuggingFace Hub下载,保障数据安全 | | trust_remote_code | true | 加载Qwen模型的自定义架构代码和chat template | | do_sample | false | 贪心解码,工具调用场景确定性优先 | | temperature | 0 | 同上 | | max_new_tokens | 1024 | 限制单次生成长度,防止模型失控时无限生成 | | max_input_tokens | 4096 | 上下文窗口上限,超长输入需要截断 | | tool_calling.mode | prompt_json | 通过prompt注入工具描述,非原生function calling API | | save_raw_output | true | 保存模型原始输出供调试 | | save_ai_message | true | 保存标准化AIMessage | ### 4.2 模块流程设计 B4的处理流程分为六个严格顺序执行的阶段: ```mermaid flowchart TD A["阶段1 配置加载 _load_model_config() 读取model.yaml"] --> B["阶段2 输入校验 validate_messages() 校验消息格式"] B --> C{"阶段3 模式分支 mode == ?"} C -- "mock" --> D["Mock生成 _mock_generate() 确定性模拟"] C -- "prompt_json" --> E["阶段4 Prompt构建 _build_prompt_messages() 双保险策略注入"] E --> F["阶段5 模型推理 _prompt_json_generate() apply_chat_template → generate → decode"] F --> G["阶段6 输出解析 _parse_model_output() 三层解析策略"] D --> H["结果封装 _candidate_to_message() 互斥约束检查"] G --> H H --> I["持久化输出 raw_model_output + ai_message"] ``` | 阶段 | 核心函数 | 输入 | 输出 | 关键设计决策 | | ---------- | --------------------------- | --------------------------- | ----------------------- | ------------------------------------------------------------ | | 配置加载 | `_load_model_config()` | model_config路径 | 配置字典 | YAML驱动,支持相对路径解析,与代码逻辑解耦 | | 输入校验 | `validate_messages()` | messages, tools_schema | 校验结果 | 复用schemas.py确保消息格式合规,tools_schema必须为list | | 模式分支 | `generate_ai_message()`内部 | mode参数 | 路由结果 | "mock"用于调试和CI测试,"prompt_json"用于生产 | | Prompt构建 | `_build_prompt_messages()` | messages, tools_schema | prompt_messages | 双保险策略+ToolMessage特殊处理+enable_thinking=False | | 模型推理 | `_prompt_json_generate()` | config, prompt_messages | raw_text | \_MODEL_CACHE缓存复用+只解码新token+无梯度推理 | | 输出解析 | `_parse_model_output()` | raw_text | (candidate, ai_message) | 三层递进解析(直接JSON→尾部反引号→tool_calls片段),由严到宽 | | 结果封装 | `_candidate_to_message()` | candidate | ai_message | unknown_key检查+schemas深度校验+content/tool_calls互斥 | | 持久化 | `generate_ai_message()`尾部 | artifact_dir, artifact_stem | 两个调试文件 | 完整推理过程可追溯,artifact_stem按llm_call_001递增 | 异常情况处理:模型加载失败(显存不足/文件缺失)→ 捕获OSError/RuntimeError,返回status="error";输入格式错误(messages非数组/tools_schema非列表)→ 前置校验raise ValueError,避免浪费模型计算;模型输出无法解析 → 三层解析策略逐级降级,全部失败则返回PARSE_ERROR_CONTENT和status="error";content与tool_calls同时存在或同时为空 → 互斥检查失败,返回status="error"。所有错误路径最终都收敛到统一的错误响应格式,确保B1能以一致的方式处理B4的所有故障情况。 ### 4.3 具体实施方案设计 **函数式架构与模型缓存机制** B4采用函数式无状态架构,核心入口`generate_ai_message()`不依赖任何外部对象的状态。唯一的状态载体为模块级全局变量`_MODEL_CACHE`,用于在Agent Loop中避免重复加载模型。实现如下: ```python title="模型缓存设计" _MODEL_CACHE: dict[tuple[str, ...], tuple[Any, Any]] = {} def _model_cache_key(model_path, tokenizer_path, local_only, trust_remote_code, dtype, device_map, max_memory): """构造缓存键,覆盖所有可能影响模型行为的配置参数""" return (str(model_path), str(tokenizer_path), str(local_only), str(trust_remote_code), str(dtype), str(device_map), str(max_memory)) def _load_model_bundle(auto_model, auto_tokenizer, model_path, tokenizer_path, **kwargs): cache_key = _model_cache_key(...) cached = _MODEL_CACHE.get(cache_key) if cached is not None: print("model_cache=hit", file=sys.stderr) return cached print("model_cache=miss", file=sys.stderr) tokenizer = auto_tokenizer.from_pretrained(...) model = auto_model.from_pretrained(...) _MODEL_CACHE[cache_key] = (tokenizer, model) return tokenizer, model ``` 缓存键的设计思路是:包含模型路径、tokenizer路径、local_files_only、trust_remote_code、dtype、device_map、max_memory等参数,切换任何配置会自动触发cache miss,加载新模型,无需手动清空缓存。在典型Agent任务中(3-5轮工具回路),缓存可将总推理时间从数十秒缩短到数秒。缓存值设计为存储`(tokenizer, model)`元组,因为tokenizer和模型总是一同使用、一同切换,绑定存储可避免不一致风险。选用模块级全局变量而非类实例属性的考量在于:B4的目标是函数式接口,B1只需调用函数即可,无需维护类的实例,降低B1的复杂度。 **Prompt双保险策略** 设计了`_build_prompt_messages()`函数,通过双重冗余约束最大化4B模型输出合法JSON的概率: 第一保险`format_instruction`以长格式注入system message,包含完整的JSON格式说明、两个schema的示例(schema A为最终回答`{"content":"...","tool_calls":[]}`,schema B为工具调用`{"content":"","tool_calls":[{"id":"call_001","name":"file_reader","args":{...}}]}`)、顶层key约束(`content`为string,`tool_calls`为array)以及多项禁止事项(禁止markdown、禁止代码块、禁止解释文字、禁止嵌套tool_calls到content中)。选择system message作为载体的原因是模型对system message中的指令遵循度通常较高。 第二保险`envelope_reminder`以短格式追加到最后一个user message,核心信息高度浓缩——强调首字符必须是`{`、末字符必须是`}`、禁止任何反引号和markdown、使用精确的顶层key、二选一schema。将其放在user message而非system message中的设计考量是:对于Qwen等ChatML格式的模型,user message在`apply_chat_template`后的token序列中位置更靠近生成起点,模型对其注意力权重通常更高。双保险的设计确保即使模型忽略了一层约束,仍有另一层作为后备。 第三层加入了ToolMessage特殊处理——当对话最后一条是tool message(即刚完成一轮工具执行)时,B4追加一条引导性user message,明确指示"ToolMessage已包含工具结果,如果信息充足请用schema A回答,且不要重复已完成的工具调用"。这有效缓解了模型在工具执行后仍重复发起相同工具调用的常见错误模式,是提升多轮调用成功率的关键优化。 同时,配置中设置了`enable_thinking=False`关闭Qwen3.5的CoT思考模式,避免`<\|thinking\|>`标签污染JSON输出。`trust_remote_code=True`确保能正确加载Qwen模型的自定义架构和chat template。 **模型加载与缓存优化** 模型加载是B4性能的关键环节。通过`_load_model_bundle()`使用`_MODEL_CACHE`全局缓存字典避免在Agent Loop的多轮调用中重复加载模型。缓存键的构造包含所有可能影响模型行为的配置参数(模型路径、tokenizer路径、local_files_only、trust_remote_code、torch_dtype、device_map、max_memory),这意味着切换任何配置(如从bfloat16改为float16,或更换模型路径)会自动触发cache miss,加载新模型,而无需手动清空缓存。在典型Agent任务中(3-5轮工具回路),缓存可将总推理时间从数十秒缩短到数秒。此外,通过向`sys.stderr`打印`model_cache=hit`或`model_cache=miss`提供可观测性,便于开发阶段的性能调优。 选用模块级全局变量而非类实例属性的设计考量在于:B4的目标是函数式接口——`generate_ai_message()`不依赖任何外部对象的状态。如果使用类来管理缓存,则B1需要维护类的实例并在每次调用时传入,增加了B1的复杂度。模块级缓存将状态管理完全封装在B4内部,B1无需关心模型是否已加载、是否需要释放,只需调用函数即可。这种设计在Agent Loop的上下文中尤为重要,因为B1的核心逻辑已经足够复杂(状态机转换、工具执行分发、记忆管理),不应再承担资源管理的职责。 **模型推理流程** 模型推理在`_prompt_json_generate()`中完成,流程如下: ```python title="模型推理流程" inputs = tokenizer.apply_chat_template( prompt_messages, tokenize=True, add_generation_prompt=True, return_tensors="pt", return_dict=True, enable_thinking=False, ) device = next(model.parameters()).device inputs = inputs.to(device) input_length = inputs["input_ids"].shape[-1] options = { "max_new_tokens": int(gen_cfg.get("max_new_tokens", 1024)), "do_sample": bool(gen_cfg.get("do_sample", False)), } with torch.no_grad(): generated = model.generate(**inputs, **options) new_tokens = generated[0][input_length:] return tokenizer.decode(new_tokens, skip_special_tokens=True) ``` 设计要点包括:(1)`apply_chat_template`自动处理ChatML格式转换,确保对话消息符合Qwen3.5的模板要求;(2)`add_generation_prompt=True`在输入末尾添加assistant角色的起始标记,引导模型以助手身份回复;(3)`torch.no_grad()`消除梯度计算,节省显存并加速推理;(4)只解码`input_length`之后的token,避免将输入prompt重复解码,确保返回纯生成内容;(5)`skip_special_tokens=True`移除`<|endoftext|>`、`<|im_end|>`等特殊token,使下游JSON解析器能直接处理纯文本。 `do_sample: false`与`temperature: 0`的组合实现贪心解码——模型在每一步选择概率最高的token。这在工具调用场景至关重要:非确定性采样可能导致相同输入产生不同的工具选择或参数值,使得Agent行为不可复现、难以调试。 **三层输出解析策略** 设计了三层递进的输出解析策略,由严到宽逐步降级: ```mermaid flowchart TD A["原始输出 raw_text"] --> B{"策略1: 直接JSON解析 json.loads(raw_text.strip())"} B -- "成功" --> Z["返回解析结果"] B -- "JSONDecodeError" --> C{"策略2: 尾部反引号处理 _parse_json_with_backtick_tail() raw_decode精确找JSON结束位置"} C -- "成功" --> Z C -- "JSONDecodeError" --> D{"策略3: tool_calls片段提取 _parse_tool_calls_fragment() 搜索tool_calls标记 提取方括号内数组"} D -- "成功" --> Z D -- "失败" --> E["抛出异常 返回PARSE_ERROR_CONTENT"] ``` 策略一处理直接合法JSON,时间复杂度O(n),是最快的路径。策略二使用`json.JSONDecoder().raw_decode()`精确找到JSON对象的结束位置,处理模型输出markdown代码块闭合标记的情况,能正确处理JSON内部嵌套大括号。策略三搜索`"tool_calls":[`或转义的`"tool_calls":[`标记,提取方括号内数组,包装为`{"content":"","tool_calls":[...]}`,针对模型输出混合文本的情况。三层形成由严到宽的容错梯度,任何一步失败则向上抛出异常。 **AIMessage互斥约束** ```python title="AIMessage互斥校验" def _candidate_to_message(candidate: dict) -> tuple[dict, dict]: if not isinstance(candidate, dict): raise ValueError("model output JSON must be an object") expected_keys = {"content", "tool_calls"} unknown_keys = set(candidate) - expected_keys if unknown_keys: raise ValueError(f"model output JSON contains unknown keys: {unknown_keys}") message = { "role": "assistant", "content": candidate.get("content", ""), "tool_calls": candidate.get("tool_calls", []), } validate_ai_message(message) has_content = bool(message["content"].strip()) has_tool_calls = bool(message["tool_calls"]) if has_content == has_tool_calls: raise ValueError("must contain either final content or tool calls, but not both") return {"content": message["content"], "tool_calls": message["tool_calls"]}, message ``` 实现了四层校验层层递进:类型检查确保顶层结构合法;unknown_key检查防止模型输出多余字段;`validate_ai_message()`调用schemas.py对每个tool_call进行`normalize_tool_call()`标准化,兼容OpenAI风格`{"function":{"name":...,"arguments":...}}`和简写风格`{"name":...,"args":...}`两种格式;互斥约束确保模型必须做出明确决策——要么有内容直接回答,要么有工具调用需要外部数据。函数返回两个对象——简化字典用于后续处理,完整message用于持久化。 **Mock模式** Mock模式通过`_mock_generate()`在无模型环境下提供确定性输出。首次调用时返回file_reader工具调用且参数固定(模拟"先读取文件再总结"的决策模式),获得工具结果后根据状态分支——工具执行成功则调用`_three_points()`函数将内容总结为三条中文要点,工具执行失败则返回包含错误详情的错误消息。这种确定性输出使得B1的状态机转换可以被精确测试,无需等待模型推理,也无需GPU资源,可极大加速集成调试的迭代速度。同时,Mock模式也作为CI/CD流水线中自动化测试的基础,确保代码修改不会破坏B1与B4的接口契约。 **错误处理** B4的错误处理遵循快速失败加隔离墙原则。所有内部异常(模型加载失败、JSON解析失败、校验不通过)被`generate_ai_message()`的try-except块捕获,统一返回`ai_message`(内容为PARSE_ERROR_CONTENT)、`status="error"`、`error={type, message}`。B1在调用B4后检查`llm_status`,若为"error"则设置`status="llm_parse_error"`并终止Agent Loop。这种设计确保一个解析错误不会导致整个系统崩溃或进入无限循环,用户至少能获得"模型输出解析失败"的明确反馈而非无响应或异常堆栈。 `PARSE_ERROR_CONTENT`使用中文直接面向终端用户("模型输出解析失败,无法生成有效工具调用或最终回答。"),是B4为数不多的面向用户的字符串,体现模块在错误场景下保持用户友好的设计哲学。从系统架构角度看,B4作为B1与LLM之间的隔离墙,其错误处理机制保护了整个Agent系统的稳定性——即使模型输出完全失控(如生成无限长文本、输出非法字符等),B4也能将其限制为一次失败的LLM调用,而不会导致B1状态机混乱或系统资源耗尽。 --- ## 5. 与其他模块的连接和融合方式 **模块接口关系** | 连接方向 | 数据流 | 说明 | | --------------- | ----------------------------------------------------------------------- | --------------------------------------------------------------------- | | B1 → B4 | model_config, messages, tools_schema, mode, artifact_dir, artifact_stem | B1通过lazy import代理调用B4,传递完整上下文 | | B4 → B1 | ai_message, status, error | B1检查status决定继续循环或终止,检查tool_calls决定执行工具或输出答案 | | B3 → B4(via B1) | tools_schema | B3将YAML工具定义转为OpenAI function calling格式,经B1传递给B4 | | B5 ↔ B4(间接) | messages中含B5注入的历史记忆 | B5不直接与B4交互,记忆通过B1维护的messages间接传递 | | B2 — B4 | 无直接交互 | B2提供具体Skill函数实现,B4通过B1→B3→tools_schema链路间接了解可用工具 | 输入输出字段已统一:B4的输入messages遵循OpenAI chat completions格式(role/content/tool_calls),tools_schema遵循OpenAI function calling格式(type/function/name/description/parameters),ai_message输出同样遵循该格式。这种格式统一是模块间无缝协作的基础——B1无需关心B4内部使用什么模型、什么推理框架,只需按照标准格式准备输入和解析输出;B4也无需关心messages中的内容来自用户原始输入还是B5注入的历史记忆,只需按照标准格式处理。B1在调用B4前后负责维护messages列表的完整性,将新的AIMessage和ToolMessage按顺序追加,确保每一轮LLM调用都能看到完整的对话上下文。 B4与B5的间接协作体现了记忆管理的优雅设计。B5的记忆内容(历史对话摘要、全局知识)通过B1注入到messages中,B4对这些消息的来源完全透明——它看到的只是标准的user/assistant/tool消息序列,无需关心哪些是原始对话、哪些是记忆注入。这种透明性使得B4的逻辑保持纯粹,不需要关心消息的来源和生成方式,同时也允许B5灵活调整记忆注入策略而不影响B4的实现。 B4与B2之间虽然没有直接交互,但通过tools_schema形成了紧密的协作关系。B2中的每个Skill函数(file_reader、calculator等)都通过B3注册为可用工具,B3将函数签名、描述、参数类型信息编码为OpenAI function calling格式的JSON schema,传递给B4。B4根据这些描述理解每个工具的功能和参数要求,在决策时选择合适的工具并构造正确的参数。这种间接关系使得B4可以专注于决策逻辑,而无需关心工具的具体实现细节,符合关注点分离的设计原则。 ```mermaid sequenceDiagram participant U as 用户 participant B1 as B1 Runtime participant B5 as B5 Memory participant B4 as B4 LLM决策 participant B3 as B3 ToolLayer U->>B1: 用户请求 B1->>B5: 查询历史记忆 B5-->>B1: 返回相关messages B1->>B3: 请求tools_schema B3-->>B1: 返回OpenAI格式工具描述 B1->>B4: generate_ai_message(model.yaml, messages, tools_schema) Note over B4: Prompt构建 → 模型推理 → 输出解析 B4-->>B1: ai_message: tool_calls=[file_reader] B1->>B3: execute_tool_calls(file_reader) B3-->>B1: tool_result: 文件内容 B1->>B1: messages.append(tool_message) B1->>B4: generate_ai_message(更新后messages) Note over B4: ToolMessage特殊处理 → 再次推理 B4-->>B1: ai_message: content=总结文本 B1->>B5: 保存对话记忆 B1-->>U: 返回最终回答 ``` B1调用B4的代理机制采用lazy import,避免了B1与B4之间的循环依赖问题,同时确保Mock模式下不加载transformers等大型库: ```python def generate_ai_message(*args, **kwargs): from b4_local_agent_llm import generate_ai_message as b4_generate_ai_message return b4_generate_ai_message(*args, **kwargs) ``` B1对返回结果的检查逻辑如下:若`status != "success"`则设置`status="llm_parse_error"`并终止Agent Loop;若`tool_calls`为空则提取`content`作为最终回答并终止循环;否则调用B3执行工具并将ToolMessage追加到messages,继续下一轮循环。`artifact_stem`采用`llm_call_{序号:03d}`的命名规范,确保多轮调用时文件名按字典序排列,方便事后分析完整的决策链条。两个调试文件(`raw_model_output.json`、`_ai_message.json`)共同构成了每次LLM调用的完整快照,是排查模型行为问题的关键证据。当`artifact_stem=None`时(例如独立运行而非被B1调用时),输出文件以当前时间戳命名,保证每次运行不覆盖之前的输出。 --- ## 6. 实验计划 ### 6.1 运行环境 | 配置项 | 值 | | --------------- | ---------------------------------------------------------------------------- | | 操作系统 | Ubuntu 22.04 LTS | | Python版本 | 3.10 | | 主要依赖 | transformers 4.40+, torch 2.1+, PyYAML | | GPU | NVIDIA GPU,显存 ≥ 12GB | | CUDA版本 | 12.1+ | | 模型 | Qwen3.5-4B-Instruct,本地路径`/root/siton-pub/assignment_B/Qwen3.5-4B` | | 精度 | bfloat16 | | 设备映射 | auto | | 最大输入token | 4096 | | 最大新生成token | 1024 | | 解码策略 | do_sample=false, temperature=0, top_p=1(贪心解码) | | 工具调用模式 | prompt_json | | 可用工具 | calculator, file_reader, local_file_search, table_analyzer, format_converter | | 运行模式 | local_files_only=true, trust_remote_code=true | | 调试输出 | save_raw_output=true, save_ai_message=true | ### 6.2 测试数据或输入样例 **场景一:初始请求生成tool_call** ```bash python b4_local_agent_llm.py --model_config ../configs/model.yaml \ --messages ../data/messages/messages_no_tool.json \ --tools_schema ../data/messages/tools_schema_basic.json \ --mode prompt_json --outdir ../outputs/B4_llm/no_tool_real ``` 输入messages包含SystemMessage和HumanMessage(用户请求"帮我阅读docs/agent_intro.txt,总结三条中文要点")。验证要点:模型能否正确判断需要调用file_reader工具,生成含正确参数的tool_calls(path指向docs/agent_intro.txt,max_chars为2000),content为空字符串。同时检查raw_model_output.json首字符为`{`、末字符为`}`,其中可见双保险格式说明。 **场景二:基于ToolMessage生成最终回答** ```bash python b4_local_agent_llm.py --model_config ../configs/model.yaml \ --messages ../data/messages/messages_with_tool.json \ --tools_schema ../data/messages/tools_schema_basic.json \ --mode prompt_json --outdir ../outputs/B4_llm/with_tool_real ``` 输入messages追加了上一轮AIMessage(含file_reader的tool_call)和ToolMessage(含文件读取结果)。验证要点:模型能否利用ToolMessage中的文件内容生成高质量中文摘要,tool_calls为空列表,不再重复调用工具。检查第二轮raw_model_output.json中可见ToolMessage特殊处理追加的引导消息。 **场景三:工具调用失败处理** ```bash python b4_local_agent_llm.py --model_config ../configs/model.yaml \ --messages ../data/messages/messages_with_error_tool.json \ --tools_schema ../data/messages/tools_schema_basic.json \ --mode prompt_json --outdir ../outputs/B4_llm/error_tool_real ``` 输入messages中的ToolMessage包含status="error"(文件不存在)。验证要点:模型能否正确处理错误ToolMessage,生成用户友好的错误说明,不尝试重复调用已失败的工具。检查ai_message.json中content包含错误说明,tool_calls为空。此场景验证B4在工具链路异常时的鲁棒性——真实使用中文件不存在、路径错误等情况经常发生,B4需要 gracefully 处理这些异常而非崩溃或陷入死循环。 **进阶验证方向** | 方向 | 实验内容 | 预期改进 | | ---------------- | -------------------------------- | ---------------------------------------------- | | 多tool_calls支持 | 测试单轮生成多个tool_calls的解析 | 提升复杂任务的并行处理能力 | | Plan-and-Execute | 实现先规划再执行的模式 | 参考HuggingGPT论文[3][6],提升多步骤任务成功率 | | 模型切换 | 支持根据任务需求切换不同本地模型 | 简单任务用轻量模型,复杂任务用大模型 | | Prompt方式对比 | 对比prompt注入与内置传参的效果 | 确定最优工具描述传递方式 | | 成功率统计 | 统计不同场景下工具调用的成功率 | 量化评估模型表现,指导优化方向 | ## 8. 参考文献 [1] Qwen3.5-4B Model. [https://www.modelscope.cn/models/Qwen/Qwen3.5-4B](https://www.modelscope.cn/models/Qwen/Qwen3.5-4B) [2] Yao S, Zhao J, Yu D, et al. ReAct: Synergizing Reasoning and Acting in Language Models. [arXiv:2210.03629](https://arxiv.org/abs/2210.03629), 2022. [3] Shen Y, Song K, Tan X, et al. HuggingGPT: Solving AI Tasks with ChatGPT and its Friends in Hugging Face. [arXiv:2303.17580](https://arxiv.org/abs/2303.17580), AAAI 2024. [4] Qin Y, Liang S, Ye Y, et al. ToolLLM: Facilitating Large Language Models to Master 16000+ Real-world APIs. [arXiv:2307.16789](https://arxiv.org/abs/2307.16789), 2023. [5] Packer C, Fang V, Patil S G, et al. MemGPT: Towards LLMs as Operating Systems. [arXiv:2310.08560](https://arxiv.org/abs/2310.08560), 2023. [6] Wu Q, Bansal G, Zhang J, et al. StateFlow: Enhancing LLM Task-Solving through State-Driven Workflows. [arXiv:2403.11322](https://arxiv.org/abs/2403.11322), 2024. [7] HuggingFace. Transformers Documentation. [https://huggingface.co/docs/transformers](https://huggingface.co/docs/transformers) --- ## 人工智能实训Day3:Agent智能体实践——工具调用与多技能协作 URL: https://xingwangzhe.fun/posts/ai-training-agent-day3/ License: CC-BY-NC-SA-4.0 前置声明:**本图文存在AI辅助整理** Day1 配环境、Day2 跑微调,到了 Day3 终于进入我最期待的环节——**Agent 智能体**。如果说 Day1 是"磨刀"、Day2 是"砍柴",那 Day3 就是"造一个能自己找刀、自己砍柴的机器人"。 说实话,之前对大模型的认知一直停留在"问答"层面,给个 prompt 它回一段话。但 Agent 这个概念打开了一扇新的大门:模型不再只是被动回答问题,而是能**主动调用工具**、**规划任务步骤**、**协作完成复杂目标**。今天的实训让我真切感受到了这种能力的魅力。 ## 前言 今天的内容来自小牛翻译 & 东北大学自然语言处理实验室的「Agent 前置训练课程」。整个实训围绕一个核心目标:学会如何让大模型(Qwen3-1.7B)通过工具调用完成实际任务。 ![Agent 课程封面](/AI/封面-day3-2.webp) 用到的技术栈: - **vLLM**:本地模型服务化部署,提供 OpenAI 兼容接口 - **AutoGen**:微软开源的 Agent 框架,支持多 Agent 协作和工具调用 - **自定义 Skill**:把 Python 函数封装成工具,让 Agent 按需调用 服务器依然是那台 **NVIDIA H200 NVL**(Docker 分配约 23GB),不过今天跑的是 Qwen3-1.7B 这个轻量级模型,显存压力比 Day2 的 SFT/DPO 小多了。 ## Problem (agent1): 什么是 Agent?为什么需要工具调用? ### (a) 从"聊天"到"做事":Agent 的本质 大模型最开始的形态是聊天机器人——你说一句,它回一句。但这种模式有个明显的天花板:模型只能依赖预训练知识,无法获取实时信息,也无法执行实际操作。 Agent(智能体)的核心思想是:**给大模型配备工具,让它在推理过程中自主决定何时调用什么工具,然后根据工具的返回结果继续推理,直到完成任务。** 这就像给一个人配备了计算器、百科全书和记事本。遇到问题,他先判断需要什么工具:需要计算就拿起计算器,需要查资料就翻开百科全书,需要记录就打开记事本。工具的使用是循环的——用了一次之后,带着新的信息继续思考,可能还需要再用其他工具。 ### (b) 今天实训的整体架构 ``` 用户任务 → Agent(Qwen3-1.7B)→ 判断需要什么工具 ↓ 调用工具(计算/读取/生成等) ↓ 获取工具返回结果 ↓ 继续推理 → 完成任务 ``` 模型通过本地的 vLLM 服务提供 OpenAI 兼容接口,代码里用 AutoGen 框架创建 Agent 并把自定义工具注册给它。整个过程都在内网服务器上完成,不需要调用任何外部 API。 ## Problem (agent2): 环境准备与模型部署 ### 代码目录结构 今天的代码 organized 得很好,四个任务分别放在四个目录里: ``` agent_practice/ ├── task1/ # 基础工具调用 ├── task2/ # 多 Skill 数据分析 ├── task3/ # GSM8K 批量推理(选做) └── task4/ # 知识库问答(选做) ``` ![进入代码目录](/AI/agent_cd.webp) ### 启动 Qwen3-1.7B 模型服务 用 vLLM 启动本地模型服务,提供 OpenAI 兼容接口。端口号改成了我们组的 8005: ```bash title="启动 vLLM 模型服务" CUDA_VISIBLE_DEVICES=0 vllm serve Qwen3-1.7B \ --host 127.0.0.1 \ --port 8005 \ --enable-auto-tool-choice \ --tool-call-parser hermes ``` 几个关键参数: - `--enable-auto-tool-choice`:允许模型根据任务自动选择工具调用 - `--tool-call-parser hermes`:指定工具调用解析器,使模型输出能被正确解析为工具调用 ![模型服务启动](/AI/model_start.webp) 启动成功后,服务监听在 `http://localhost:8005/v1`,代码里的 `openai_api_base` 都要对准这个地址。 ## Problem (agent3): 任务一——工具调用 Agent ### (a) 任务目标 让 Agent 同时拥有两个工具:**安全计算器**(safe_calculator)和**单位转换器**(unit_converter)。Agent 必须使用工具完成数学计算和单位换算,最后用中文解释结果。 ### (b) 工具设计 **safe_calculator** 使用 `ast.parse` 把表达式解析成抽象语法树,再递归计算。这比直接用 `eval` 安全多了——只允许特定的运算符和函数(sqrt、abs、round 等),根本执行不了任意代码。 **unit_converter** 支持长度和质量单位的换算。核心思路是"先统一到基准单位,再从基准单位换算到目标单位"——比如 km→m 是先乘 1000 统一到米,再除以目标单位的比例。 ### (c) 代码与运行 ```python title="run_task1.py" agent = llm_factory.create_agent( name="local_qwen_tool_agent", model="Qwen3-1.7B", tools=[safe_calculator, unit_converter], # 注册两个工具 system_message=( "You are an assistant proficient in using tools. " "When encountering mathematical calculations, " "you must use safe_calculator. " "When encountering unit conversions, " "you must use unit_converter. " "Summarize results in Chinese." ), max_tool_iterations=5, ) ``` 任务 prompt: ``` Calculate: (23 * 17 + sqrt(81)) / 5 Convert 3.2 km to m Finally, summarize the results in chinese. ``` ![任务一运行结果](/AI/task1_run.webp) ### (d) 结果分析 Agent 完成了两次工具调用: | 任务 | 工具 | 参数 | 返回 | | -------- | --------------- | -------------------------- | ----------------- | | 数学计算 | safe_calculator | `(23 * 17 + sqrt(81)) / 5` | 80.0 | | 单位换算 | unit_converter | `3.2, "km", "m"` | 3.2 km = 3200.0 m | 最后用中文总结了结果。整个过程模型自己判断需要什么工具、传什么参数,完全不需要人工干预。这就是 Agent 的魅力——**你只需要说"做什么",不需要说"怎么做"**。 ## Problem (agent4): 任务二——销售数据分析 Agent ### (a) 任务目标 让 Agent 使用**三个 Skill**(MathSkill、SalesDataSkill、ReportSkill)完成销售数据分析:读取 CSV → 汇总数据 → 计算指标 → 生成 Markdown 报告。 ### (b) 三个 Skill 的设计 这个任务比任务一复杂得多,需要多个工具协作完成。设计上把功能分成三个 Skill,每个 Skill 负责一块: | Skill | 工具 | 作用 | | -------------- | --------------------- | ---------------------------- | | MathSkill | safe_calculator | 精确计算利润率和平均销售额 | | SalesDataSkill | summarize_sales_csv | 读取 sales.csv,按产品汇总 | | ReportSkill | write_markdown_report | 把分析结果写入 Markdown 文件 | 三个 Skill 通过 `skills/__init__.py` 合并成一个 `ALL_TOOLS` 列表,创建 Agent 时统一传入。 ### (c) 关键:system_message 的设计 任务二让我深刻体会到了一个关键点:**system_message 怎么写,直接决定了 Agent 会不会正确调用工具。** 我的 system_message 里明确约束了三种情况: ```python system_message = ( "You are a sales data analysis agent. " "You have three skills:\n" "1. MathSkill: Used for precise calculations.\n" "2. SalesDataSkill: Used for reading and summarizing sales CSV.\n" "3. ReportSkill: Used for writing Markdown reports.\n" "When the task involves CSV analysis, " "the summarize_sales_csv function must be called.\n" "When the task involves precise calculations, " "the safe_calculator must be invoked.\n" "When a task requires generating a report file, " "the write_markdown_report function must be called.\n" "The final answer must be in Chinese." ) ``` 注意这里用了"must be called"这种强制性措辞。如果写得太温和(比如"you can use"),模型可能会偷懒直接心算,不调用工具。 ### (d) 运行结果 ![任务二运行结果](/AI/task2_run.webp) Agent 按顺序调用了三个工具: 1. **summarize_sales_csv** → 读取 sales.csv,返回总销售额 751.0、总成本 434.0、总利润 317.0 等汇总数据 2. **safe_calculator** → 计算利润率 42.21% 和平均每件销售额 9.99 3. **write_markdown_report** → 将结果写入 workspace/sales_report.md 生成的报告包含所有要求的指标,结论明确。这个任务让我理解了**Skill 的模块化设计**——把不同功能拆成独立的 Skill,既方便复用又方便维护。 ## Problem (agent5): 任务三——GSM8K 批量推理(进阶) ### (a) 任务目标 复用任务一的安全计算工具,对 GSM8K 数据集中的 10 道数学题进行**批量求解**,统计准确率。 ### (b) 核心设计:避免上下文污染 批量任务最大的坑是**上下文污染**。如果让同一个 Agent 连续做 10 道题,前面题目的推理过程会干扰后面的判断。 解决方案:**为每道题创建一个独立的 Agent 实例**(name 带题号),每道题都是"fresh start": ```python title="独立 Agent 实例" agent = llm_factory.create_agent( name=f"gsm8k_math_agent_{idx}", # 每道题独立 model=model, tools=[safe_calculator], system_message=( "...Output FINAL_ANSWER: ..." ), max_tool_iterations=8, ) ``` ### (c) 运行结果 ![任务三运行结果](/AI/task3_run.webp) 10 道题的结果: | 题号 | 答案 | 预测 | 正确 | | ---- | ----- | ----- | ---- | | 1 | 60 | 60 | ✓ | | 2 | 125 | 125 | ✓ | | 3 | 230 | 230 | ✓ | | 4 | 57500 | 57500 | ✓ | | 5 | 7 | 7 | ✓ | | 6 | 6 | 6 | ✓ | | 7 | 15 | 15 | ✓ | | 8 | 14 | 2 | ✗ | | 9 | 7 | 7 | ✓ | | 10 | 8 | 8 | ✓ | **准确率:9/10 = 90%** 第 8 题错了——那是一道年龄关系推理题,Agent 在理解题意时出现了偏差。这也说明本地小模型在复杂逻辑推理上仍有局限,不过对于计算类题目表现相当不错。 ## Problem (agent6): 任务四——知识库问答与学习计划生成(进阶) ### (a) 任务目标 这是今天最复杂的任务——从零实现一个**多文档知识库问答 Agent**,完成"检索 → 提取 → 回答 → 计划 → 报告 → 验证"的完整闭环。 ### (b) 四个 Skill 的设计 | Skill | 工具 | 作用 | | ------------- | ------------------------------------------------- | ------------------ | | DocumentSkill | list_documents / search_documents / read_document | 文档检索三件套 | | PlanSkill | generate_study_plan | 生成学习计划 | | ReportSkill | write_markdown_report | 写入 Markdown 报告 | | ValidateSkill | validate_report | 验证报告完整性 | 特别想说说 **ValidateSkill** 的设计——它检查报告是否包含四个必需章节(Question Answer / Evidence Sources / Study Plan / Summary),以及是否有未替换的模板占位符。这个自检机制非常实用,相当于给 Agent 加了一个"质检员"角色。 ### (c) 有趣的自修复过程 运行过程中发生了一件很有意思的事:Agent 第一次调用 validate_report 时,报告**没有通过验证**——缺少 Study Plan 部分。但 Agent 没有停下,而是**自动修复**了问题:重新写入完整报告,再次验证,最终通过。 这个"发现错误 → 自我修正"的闭环,让我真切感受到了 Agent 的自主性。 ### (d) 运行结果 ![任务四运行结果](/AI/task4_run.webp) Agent 共完成了 12 次工具调用,最终生成的报告包含四个部分:问题回答、依据来源(引用了 course_intro.md、environment_guide.md、task_requirements.md、faq.md)、三天学习计划、总结。 ## 总结 今天的 Agent 实训让我对"大模型能做什么"有了全新的认识。从最初的"问答工具"到今天的"自主任务执行者",Agent 架构打开了无限可能: **学到的核心概念:** - **工具调用(Tool Use)**:模型不是只能说话,它可以调用函数、读取文件、执行计算 - **Skill 模块化**:把功能拆成独立的 Skill,便于复用和维护 - **system_message 设计**:怎么约束模型调用工具,措辞很关键 - **上下文管理**:批量任务要避免上下文污染,独立 Agent 实例是有效方案 - **自检机制**:validate_report 这样的工具让 Agent 能自我检查、自我修正 **四个任务的收获:** | 任务 | 核心能力 | | ------ | ------------------------- | | 任务一 | 基础工具调用闭环 | | 任务二 | 多 Skill 协作完成数据分析 | | 任务三 | 批量任务处理与准确率统计 | | 任务四 | 端到端知识库问答与自修复 | Day1 配环境、Day2 跑微调、Day3 玩 Agent——三天下来,从"会用大模型"到"会让大模型做事",感觉又上了一个台阶。 --- ## 人工智能实训Day2:大模型对齐技术实践——SFT与DPO URL: https://xingwangzhe.fun/posts/ai-training-sft-dpo-day2/ License: CC-BY-NC-SA-4.0 前置声明:**本图文存在AI修饰** > 这篇文章本来应该昨天发的,但光顾着跑模型跑了一整天,等反应过来已经到今天的实训了……赶紧补上 Day2 的记录。 ## 前言 说实话,Day1光是配环境就折腾了一整天——毕竟第一次连上那台内网服务器,从 Remote-SSH 折腾到 Zed,再到装 Miniconda、配 `ai_infer` 环境、装 PyTorch 和 CUDA 对齐,每一步都在跟依赖问题搏斗。不过好消息是,当 `nvidia-smi` 终于能正常显示我那块 **NVIDIA H200 NVL (Docker 分配约 23GB)** 的时候,心里那块石头总算是落地了。关于服务器的具体配置我在 Day1 的登录欢迎信息里已经贴过了,这里就不重复了。 今天我们进入正题——**大模型对齐(Alignment)**。如果说Day1是在"磨刀",那Day2就是真正开始"砍柴"了。 ![人工智能实训 Day2 封面](/AI/封面-day3.webp) 具体来说,我们要完成两个核心实验: 1. **SFT(Supervised Fine-Tuning,监督微调)**:用 GSM8K 数学数据集教会 Qwen1.5-0.5B-Chat 做数学题 2. **DPO(Direct Preference Optimization,直接偏好优化)**:用偏好数据进一步优化模型的回答质量 框架用的是 **LLaMA-Factory**,这个框架对大模型微调做了很好的封装,基本上只需要写好 YAML 配置文件就能一键训练。对于刚入门的人来说非常友好。 好了,话不多说,开始今天的记录。 --- ## Problem (sft1): 为什么需要大模型对齐? 在开始动手之前,我们先来理解一下:**为什么需要对齐?直接拿预训练模型来用不行吗?** ### (a) 提示工程的局限性 很多人第一次接触大模型的时候,会觉得"写好 prompt 就够了"。确实,对于一些简单的任务,精心设计的 prompt 加上 few-shot 示例,往往能得到还不错的结果。但提示工程有几个本质上的局限: - **天花板低**:模型的知识来自预训练,prompt 只是激活已有知识,无法教给模型全新的推理模式 - **不稳定**:同样的 prompt,换个问法效果可能差很多 - **无法纠正错误**:如果模型在预训练中学到了错误的模式,prompt 很难彻底纠正 以我们用的 Qwen1.5-0.5B-Chat 为例——0.5B 参数的规模,说实话在预训练阶段学到的推理能力相当有限。如果直接拿来做 GSM8K 的数学题,基本就是"瞎蒙"。 ### (b) SFT和DPO在大模型训练中的位置 大模型的训练通常分为几个阶段: | 阶段 | 名称 | 目标 | 典型数据 | | ------- | ---------------------- | -------------------------------- | ------------------- | | Stage 1 | Pre-training(预训练) | 学习语言知识和世界知识 | 海量无标注文本 | | Stage 2 | SFT(监督微调) | 学习指令遵循和对话格式 | 高质量的指令-回答对 | | Stage 3 | RLHF/DPO(对齐) | 对齐人类偏好(有用、安全、诚实) | 偏好对比数据 | > **SFT** 的作用是让模型学会"如何正确地回答问题"——包括格式、风格、和基本的推理步骤。 > **DPO** 的作用是让模型学会"什么样的回答更好"——通过对比"好的回答"和"差的回答",让模型更偏好高质量的输出。 不过有个重要的点要说明:我们今天的实验对象是 **0.5B 参数** 的模型。这个规模说实话非常小了(对比现在动辄几十上百B的模型),所以别对最终效果抱太高的期望。今天的目的更多是**理解 SFT 和 DPO 的流程和原理**,而不是训练出一个数学竞赛选手。 --- ## Problem (sft2): 环境准备与数据集 ### (a) 创建conda环境和安装LLaMA-Factory Day1 的 `ai_infer` 环境主要是为基本推理准备的,Day2 要跑 LLaMA-Factory 做微调,我另建了一个专门的环境。下面是我用到的命令: ```bash title="安装 LLaMA-Factory" # 创建新的环境用于 LLM 微调(与 Day1 的 ai_infer 分开) conda create -n llm_train python=3.10 conda activate llm_train # 安装 PyTorch(与 CUDA 版本对齐) pip install torch # 克隆LLaMA-Factory仓库 git clone https://github.com/hiyouga/LLaMA-Factory.git cd LLaMA-Factory # 安装依赖 pip install -e ".[torch,metrics]" # 验证安装 llamafactory-cli version ``` ![环境安装](/AI/sft_env_install.webp) 安装完成后,先检查一下 CUDA 是否正常工作: ```bash title="检查 CUDA 环境" python -c "import torch; print(f'PyTorch: {torch.__version__}'); print(f'CUDA: {torch.version.cuda}'); print(f'GPU: {torch.cuda.get_device_name(0)}')" ``` ![CUDA检查](/AI/sft_cuda_check.webp) 不出意外的,H200 NVL(Docker 分配约 23GB,之前 Day1 登录时也看到了)顺利识别,CUDA 环境一切正常。23GB 对于 0.5B 参数的模型来说是绰绰有余的,甚至可以考虑全量微调(Full Fine-tuning)而不是 LoRA。 ### (b) GSM8K和Math-Step-DPO-10K数据集介绍 今天用到两个数据集: **GSM8K**(Grade School Math 8K)是 OpenAI 发布的小学数学应用题数据集。它包含 **7,473 道训练题** 和 **1,319 道测试题**。每道题都需要多步推理才能得出正确答案,是测试大模型数学推理能力的经典 benchmark。 一个典型的 GSM8K 样本长这样: ```text Question: James decides to run 3 sprints 3 times a week. He runs 60 meters each sprint. How many total meters does he run a week? Answer: 540 ``` 注意这里的 answer 只有最终答案(540),没有中间推理过程。这也是为什么 GSM8K 的评测指标通常用 **Exact Match**——只看模型输出的最后数字是否和答案一致。 **Math-Step-DPO-10K** 是一个偏好数据集,约 **10,795 条数据**。它的格式和 GSM8K 不同,每条数据包含: - `question`:数学题目 - `chosen`:人类偏好的、正确的、带详细推理步骤的回答 - `rejected`:相对较差或错误的回答 这种 `chosen` vs `rejected` 的成对结构正是 DPO 训练所需要的。 ![数据准备](/AI/sft_data_prepare.webp) ![DPO数据准备](/AI/dpo_data_prepare.webp) --- ## Problem (sft3): SFT监督微调实验 ### (a) 数据准备和配置 LLaMA-Factory 的训练配置全部写在 YAML 文件里。对于 SFT,我的配置文件大致如下: ```yaml title="sft_config.yaml" model_name_or_path: Qwen/Qwen1.5-0.5B-Chat template: qwen dataset: gsm8k_train cutoff_len: 1024 max_samples: 100000 overwrite_cache: true preprocessing_num_workers: 16 # 训练配置 output_dir: saves/qwen1.5-0.5b/sft per_device_train_batch_size: 4 gradient_accumulation_steps: 4 num_train_epochs: 3.0 learning_rate: 1.0e-4 lr_scheduler_type: cosine warmup_ratio: 0.1 bf16: true ddp_timeout: 180000 # 评估 val_size: 0.1 per_device_eval_batch_size: 4 eval_strategy: steps eval_steps: 100 # 日志 logging_steps: 10 save_steps: 500 plot_loss: true ``` 这里有几个关键配置说明一下: - `template: qwen`:指定使用 Qwen 模型的对话模板,确保输入格式和预训练时一致 - `cutoff_len: 1024`:最大序列长度,数学题一般不需要太长 - `bf16: true`:使用 bfloat16 混合精度训练,节省显存的同时保持较好的数值稳定性 - `num_train_epochs: 3`:训练 3 轮,经验上对于小数据集足够了 ### (b) 训练启动和过程 配置好后,一条命令就能启动训练: ```bash title="启动 SFT 训练" llamafactory-cli train sft_config.yaml ``` ![训练启动](/AI/sft_train_start.webp) 训练开始后,终端会实时输出 loss 和 learning rate 的变化。说实话,看着 loss 从 0.8 左右一路往下掉,还是挺有成就感的。 整个 SFT 训练持续了 **约 1 小时 16 分钟**。对于 7473 条训练数据跑 3 个 epoch 来说,这个时间还是合理的。毕竟虽然是 0.5B 的小模型,但数学推理的数据序列普遍比较长,计算量不算小。 ![训练完成](/AI/sft_train_complete.webp) ### (c) 训练可视化 LLaMA-Factory 会自动生成 loss 曲线图。从曲线上可以清楚地看到训练过程的变化: ![Loss曲线](/AI/sft_loss_curve.webp) - **初始 loss**:约 0.82 - **最终 loss**:约 0.18 - **下降趋势**:比较平滑,没有明显的震荡或过拟合迹象 这个 loss 的下降说明模型确实在学习——它在逐渐适应 GSM8K 数据的分布,学会了按我们期望的格式输出数学推理过程。 ### (d) Exact Match评测 SFT 训练完成后,我们用 GSM8K 的测试集来评测。评测方式是 **Exact Match**:从模型输出中提取最后一个数字,和正确答案比对。 ```bash title="运行评测" llamafactory-cli eval eval_config.yaml ``` 评测结果: | 指标 | 数值 | | ---------------------- | --------- | | 测试集大小 | 1,319 题 | | 正确题数 | 310 题 | | **Exact Match 准确率** | **23.5%** | ![Exact Match](/AI/sft_exact_match.webp) 23.5% 的准确率,说实话不高,但对于 0.5B 参数的模型来说也并非完全不可接受。要知道,Qwen1.5-0.5B-Chat 在未经 SFT 的情况下做 GSM8K,准确率可能连 5% 都不到。经过 SFT 后从"几乎不会"提升到"每四题对一题",已经算是有意义的进步了。 这里也要理解 Exact Match 的严格性:只要最后提取的数字有一点偏差(比如多算或少算一位),就算全错。它衡量的其实是"完全正确的推理",而不是"部分正确的推理"。 --- ## Problem (sft4): DPO直接偏好优化实验 ### (a) DPO原理简介 DPO(Direct Preference Optimization)是 2023 年提出的一种大模型对齐方法。它来源于 RLHF(Reinforcement Learning from Human Feedback),但比 RLHF 更简单直接。 传统 RLHF 的流程是: 1. 训练一个 Reward Model(奖励模型)来学习人类偏好 2. 用 PPO(Proximal Policy Optimization)等强化学习算法优化策略 这个流程非常重——需要维护多个模型,训练过程也不稳定。 DPO 的核心洞察是:**其实不需要显式地训练 Reward Model,也不需要强化学习**。DPO 直接从偏好数据(chosen/rejected 对)出发,用一个简单的损失函数就能达到类似的效果。 具体来说,对于每条偏好数据,DPO 的损失函数是: $$\mathcal{L}_{DPO} = -\log \sigma\left(\beta \cdot \left[\log\frac{\pi_\theta(y_c|x)}{\pi_{ref}(y_c|x)} - \log\frac{\pi_\theta(y_r|x)}{\pi_{ref}(y_r|x)}\right]\right)$$ 其中: - $x$ 是问题(question) - $y_c$ 是 chosen(更好的回答) - $y_r$ 是 rejected(更差的回答) - $\pi_\theta$ 是当前策略(正在训练的模型) - $\pi_{ref}$ 是参考策略(通常是 SFT 后的模型,固定不更新) - $\beta$ 是温度系数,控制优化强度 用人话翻译一下:**DPO 做的事情就是,让模型在 chosen 回答上的概率尽量高,在 rejected 回答上的概率尽量低**。中间的 $\beta$ 参数控制这个"偏好"的强度。 > **Reward Margin** 是 DPO 训练中的一个关键监控指标,它表示模型对 chosen 和 rejected 的"区分能力"。理想情况下,这个值应该逐渐增大,说明模型越来越能分辨好坏回答。 ### (b) 数据准备和训练 DPO 训练使用的是 Math-Step-DPO-10K 数据集,每条数据都包含 `question`、`chosen` 和 `rejected` 三个字段。 ```yaml title="dpo_config.yaml" model_name_or_path: saves/qwen1.5-0.5b/sft # 基于SFT后的模型 template: qwen dataset: math_step_dpo_10k cutoff_len: 1024 # DPO特有配置 pref_beta: 0.1 pref_loss: dpo # 训练配置 output_dir: saves/qwen1.5-0.5b/dpo per_device_train_batch_size: 2 gradient_accumulation_steps: 8 num_train_epochs: 3.0 learning_rate: 5.0e-5 lr_scheduler_type: cosine warmup_ratio: 0.1 bf16: true ``` 注意这里 `model_name_or_path` 指向的是 SFT 训练后的模型目录,而不是原始预训练模型。这是 DPO 的标准做法——在 SFT 的基础上进一步优化。 `pref_beta: 0.1` 是 DPO 的关键超参数,后面进阶任务中我们会详细分析它的影响。 ```bash title="启动 DPO 训练" llamafactory-cli train dpo_config.yaml ``` DPO 训练比 SFT 慢不少,最终耗时 **约 4 小时 8 分钟**。原因主要是 DPO 每轮训练需要分别对 chosen 和 rejected 做两次前向传播,计算量更大。 ![DPO训练完成](/AI/dpo_train_complete.webp) 最终训练 loss 降到了 **0.1567**,整个训练过程比较稳定。 ### (c) 训练可视化 先来看 loss 曲线: ![DPO Loss曲线](/AI/dpo_loss_curve.webp) 然后是 DPO 特有的 Reward Margins 曲线: ![DPO Reward Margins](/AI/dpo_rewards_margins.webp) 这个 Reward Margins 图非常有信息量。可以看到: - **初始阶段**:Reward Margin 接近 0,说明刚开始模型对 chosen 和 rejected 的区分能力很弱 - **训练过程**:Margin 逐渐扩大,从 0 一路增长到 **约 30** - **最终状态**:模型已经能很好地区分 chosen 和 rejected 了 这说明 DPO 训练是成功的——模型学会了"什么样的回答更好"。 ### (d) SFT vs DPO对比分析 这是很多人关心的部分:DPO 之后的模型,真的比 SFT 好吗? 我们用相同的 100 道测试题分别评测了 SFT 模型和 DPO 模型: | 模型 | 测试题数 | 正确题数 | 准确率 | | ---- | -------- | -------- | ------ | | SFT | 100 | 4 | **4%** | | DPO | 100 | 1 | **1%** | ![DPO对比结果](/AI/dpo_compare_result.webp) 嗯……看到这个结果的时候,说实话我也愣了一下。DPO 的 Exact Match 准确率反而比 SFT 低了? 但仔细想想,这其实是一个很关键的认知:**DPO 的目标不是提升 Exact Match 准确率,而是优化偏好排序**。DPO 让模型更喜欢"好的回答",但"好的回答"和"正确答案"不完全是一回事。 具体来说: 1. **DPO 的优化目标** 是让模型输出更"像" chosen(更详细、更有条理、更符合人类偏好)的回答,而不是让最终数字更正确 2. **0.5B 模型的表达能力有限**,DPO 在优化偏好表达的同时,可能会牺牲一定的精确计算能力 3. **Exact Match 是一个严格的指标**,DPO 模型可能在推理步骤上更合理,但最后一步算错了,依然得 0 分 这个对比其实给了我们一个很重要的教训:**评价一个模型要看你用的是什么指标**。如果用 Reward Accuracy(模型对 chosen 的偏好是否正确),DPO 模型肯定比 SFT 好得多;但如果用 Exact Match,结果可能就没那么漂亮了。 --- ## Problem (advanced1): CoT推理时增强(进阶任务) ### (a) 5种Prompt模板设计思路 做完了基础实验,接下来我们尝试一个有趣的进阶任务:**推理时增强(Test-time Augmentation)**,具体来说就是不同的 Chain-of-Thought(CoT,思维链)Prompt 模板。 CoT 的核心思想很简单:与其让模型直接输出答案,不如让它"一步一步地想"(let's think step by step)。这种方式对于需要推理的任务非常有效。 我设计了 5 种不同的 CoT Prompt 模板,来测试哪种对小模型最有效: **1. basic(标准逐步推理)** ```text 请逐步推理并回答以下数学问题:{question} 请一步一步思考,最后给出答案。 ``` 这是最基础的 CoT 提示,只要求模型逐步推理。 **2. key_info(先提取关键条件)** ```text 请逐步推理并回答以下数学问题:{question} 请先提取问题中的关键信息,然后逐步计算,最后给出答案。 ``` 这个模板增加了一个"提取关键信息"的前置步骤,模拟人解题时先分析条件的习惯。 **3. intermediate_check(每步验证)** ```text 请逐步推理并回答以下数学问题:{question} 请在每步计算后进行验证,确保正确后再继续,最后给出答案。 ``` 这个模板要求模型在每一步计算后进行自我验证,试图减少累积错误。 **4. comprehensive(综合策略)** ```text 请逐步推理并回答以下数学问题:{question} 请先分析题意,提取关键条件,然后选择合适的解题策略, 逐步计算并在每步验证,最后给出答案。 ``` 这个模板整合了前面几种策略,是一个"大而全"的版本。 **5. self_check(推理后自检)** ```text 请逐步推理并回答以下数学问题:{question} 请一步一步思考。完成推理后,请检查你的答案是否合理, 如果发现问题请重新计算,最后给出最终答案。 ``` 这个模板在推理结束后增加了一个自检环节,让模型反思自己的答案。 ### (b) 实验结果与分析 我们在 100 道 GSM8K 测试题上分别测试了这 5 种模板。实验用的是 SFT-3(经过 SFT 训练的模型): ![CoT实验启动](/AI/cot_experiment_launch.webp) 结果如下: | 模板 | 准确率 | 描述 | | ------------------ | --------- | -------------- | | basic | **7.00%** | 标准逐步推理 | | key_info | **7.00%** | 先提取关键条件 | | intermediate_check | 6.00% | 每步验证 | | comprehensive | 5.00% | 综合策略 | | self_check | 4.00% | 推理后自检 | ![CoT结果对比](/AI/cot_results_comparison.webp) 这个结果非常有意思。**最简单的 basic 模板反而效果最好,而越复杂的模板效果越差**。 我的分析是这样的: 1. **0.5B 模型的指令理解能力有限**。复杂的 prompt 包含多个要求(提取条件 + 选择策略 + 逐步计算 + 验证),小模型很难同时执行好所有这些步骤。 2. **每增加一个指令,都可能引入新的错误点**。比如 intermediate_check 要求"每步验证",但模型可能理解不了什么是"验证",反而把验证步骤搞成了无意义的重复,打乱了正常的推理流程。 3. **basic 模板只要求"一步步想"**,这是 CoT 最核心的要素,也是最简单、最不容易出错的指令。 这个实验给我的启发是:**对于小模型来说,prompt 设计要遵循"奥卡姆剃刀"原则——能简单就别复杂**。你自以为在帮模型"理清思路",实际上可能是在增加认知负担。 --- ## Problem (advanced2): β参数敏感性分析(进阶任务) ### (a) β参数的作用 还记得 DPO 损失函数里的 $\beta$ 吗?它是 DPO 中最重要的超参数之一,直接控制着优化的"激进程度": - **$\beta$ 越大**:优化越保守,模型更靠近参考模型(SFT 模型),变化小 - **$\beta$ 越小**:优化越激进,模型可以更大幅度地偏离参考模型 $\beta$ 的本质是控制 KL 散度的惩罚强度——它限制 DPO 模型和参考模型之间的差异,防止优化过度导致模型"跑偏"。 ### (b) 三种β值的对比实验 为了找到合适的 $\beta$ 值,我跑了三组对比实验: **第一组:β = 0.05(LoRA, 1 epoch)** ```yaml title="beta 0.05 配置" pref_beta: 0.05 finetuning_type: lora num_train_epochs: 1.0 ``` ![Beta0.05训练](/AI/beta_005_train.webp) - Loss: 0.5807 - Reward Margin: ~1.17 **第二组:β = 0.1(Full Fine-tuning, 3 epochs)** ```yaml title="beta 0.1 配置" pref_beta: 0.1 finetuning_type: full num_train_epochs: 3.0 ``` 这是之前 SFT4 中用的配置,效果最好。 - Loss: 0.1567 - Reward Margin: ~31.6 **第三组:β = 0.2(LoRA, 1 epoch)** ```yaml title="beta 0.2 配置" pref_beta: 0.2 finetuning_type: lora num_train_epochs: 1.0 ``` ![Beta0.2训练](/AI/beta_020_train.webp) - Loss: 0.5109 - Reward Margin: ~2.11 ### (c) 结论与推荐 三组实验的数据汇总对比: | β | 训练方式 | Loss | Reward Margin | | ---- | -------- | ------ | ------------- | | 0.05 | LoRA 1ep | 0.5807 | ~1.17 | | 0.1 | Full 3ep | 0.1567 | ~31.6 | | 0.2 | LoRA 1ep | 0.5109 | ~2.11 | ![Beta对比](/AI/beta_comparison.webp) 这里要说明一下——三组实验的训练配置不完全一致(Full vs LoRA,3ep vs 1ep),所以不能严格地只做 $\beta$ 的单变量对比。但从趋势上还是可以得到一些结论: 1. **$\beta = 0.1$ 时 Reward Margin 最大(~31.6)**,说明模型对 chosen/rejected 的区分能力最强 2. **$\beta$ 过小(0.05)**:优化过于激进但训练不充分,模型可能偏离参考模型太远,导致 reward margin 反而小 3. **$\beta$ 过大(0.2)**:KL 惩罚太强,模型被"拉"回参考模型,优化效果受限 > **推荐**:对于类似的实验设置,$\beta$ 在 **0.05 ~ 0.1** 范围内是比较合理的选择。如果训练更充分(更多 epoch、更大 batch),可以尝试更小的 $\beta$ 来获得更强的优化效果。 --- ## 总结 到今天为止,人工智能实训第二阶段的核心任务就全部完成了。回顾一下今天的收获: | 实验 | 核心指标 | 结果 | | ------------------------ | ------------- | ----------------- | | SFT(GSM8K) | Exact Match | 23.5%(310/1319) | | DPO(Math-Step-DPO-10K) | Reward Margin | 从 0 扩大到 ~30 | | SFT vs DPO | 100题准确率 | SFT 4% vs DPO 1% | | CoT模板对比 | 最佳模板 | basic(7%) | | β敏感性分析 | 最优β | 0.05 ~ 0.1 | 几个关键的 take-away: 1. **SFT 是有效的**:0.5B 模型经过 SFT 后,GSM8K 准确率从接近 0 提升到 23.5%,证明了监督微调的价值 2. **DPO 的目标要明确**:DPO 优化的是偏好排序,不是 Exact Match。用错误的指标评价会得到令人困惑的结论 3. **CoT 提示要简洁**:对于小模型,简单的 prompt 往往比复杂的 prompt 更有效 4. **超参数调优很重要**:$\beta$ 的选择对 DPO 效果影响很大,需要根据实际实验来确定 说实话,今天的训练等待时间确实挺长的(SFT 1小时 + DPO 4小时),但在这个过程中我真正理解了对齐技术的来龙去脉,而不仅仅是跑通了代码。 **明天见。** --- ## 再造图灵 URL: https://xingwangzhe.fun/posts/redesigning-turing/ License: CC-BY-NC-SA-4.0 > **⚠ 本文存在AI生成内容** > > 请注意辨别,部分段落由 AI 辅助生成或润色 ![图灵机模拟器运行画面——虚拟纸带上红色读写头正在移动](/再造图灵-封面.webp) > 不是用神经网络复活一个死去的英国人,而是在AI引起的生产关系剧变中,重新编译他留下的那份关于自由,通用与理性的源代码. --- ## 一,引言:图灵日的运行态 2026年6月23日.我在寝室里打开了`Godot引擎`. 屏幕上跑出来一个粗糙的二维场景:一条水平展开的虚拟纸带,格子里写着0和1,一个**红色小方块充当读写头**,按照状态转移表一格一格地挪动.这是我用`GDScript`写的单带图灵机模型--完整实现了图灵机七元组,带状态寄存器和纸带寻址,虽然以今天的标准看,这代码写得跟用凿子刻石板差不多. 我点了运行.读写头开始移动.写下0,擦除,改成1,右移,再左移.纸带上的符号串在变化,像某种极简主义的电子禅. 八十年前,**图灵**在一张纸上想象了这台机器.不是真的造出来--他不需要.他用数学的杠杆撬开了"可计算"的边界,证明了通用机的存在,然后就把草图搁一边,去 longer 的赛道上跑步了(他真的很爱跑步).八十年后,我坐在寝室里,用一台笔记本,以每秒六十帧的帧率,平滑地,流畅地,近乎奢侈地模拟着那台想象中的装置. 这中间的跨度有点荒谬.**图灵**写那篇论文的时候,世界上还没有能运行的电子计算机;今天我的笔记本上同时跑着图灵机模拟器,`Stable Diffusion`权重文件,三个浏览器标签页和一段还没调通的`BERT`微调脚本.**通用计算**--这个**图灵**用思想实验预言的东西--迟到了几十年,但最终以一种他无法想象的方式兑现了. 每年6月23日,互联网上会飘满"纪念图灵诞辰"的推文和科普文章.但我今年不太想谈纪念.**纪念**是把一个人供进神殿,**再造**是把他从神殿里请出来,拍掉身上的灰,问他:你说的通用机,智能,自由--这些东西在今天变成什么了? 从**Ada Lovelace**在差分机笔记里写下第一个算法,到今天开源权重在`HuggingFace`上被全球下载,计算的史诗始终有两个声部:技术的狂飙突进,与普通人试图不被这趟列车甩出车厢的挣扎.**图灵**站在中间某个节点上,既是狂飙的推手,也是最终被列车碾过的人. 让我们往回走一段.有些被删掉的历史,得先找回来. --- ## 二,被抹去的先驱:织机上的代码 #### Ada Lovelace(1815–1852) 1843年,一位二十七岁的英国贵族女子在一沓稿纸上写下了一组操作序列:计算伯努利数的步骤,每一步都精确到齿轮该往哪转,寄存器该存什么.这是人类历史上第一个计算机程序--写在纸上,写给一台从未建成的机器,写在一百多年前. 她是怎么看**Babbage**的分析机的?不是把它当成一台更快的算盘,而是--用她自己的话说--"一台能编织代数模式的`Jacquard`织机". `Jacquard`织机是那个年代的黑科技:用打孔卡片控制经纬线,织出复杂的图案.Lovelace看懂了本质.她意识到,如果一台机器能通过打孔卡片控制织物的图案,那么另一台机器就能通过同样的方式控制数字的模式--甚至控制音乐的音符,代数的关系,任何能用符号表达的东西.她跳出了"计算"的牢笼,预见到了通用性. 这比**图灵**早了近一百年.**图灵**得到了定理,论文和学术论文引用链;Lovelace得到的,是她同时代人的礼貌性沉默,以及后世历史学家不断争论"她到底有没有那么厉害"的消耗战. 为什么?几件事叠加.她是女的.分析机最终没有建成,程序也就从未真正"运行".最重要的是,她谈论的东西--"机器能不能作曲","机器是不是在做原创性工作"--被视为"形而上学思考",不严谨,不像个正经的数学家.**一个看到了未来的人,因为看得太远,被判定为不务正业**. #### Bletchley Park(1940s) 战争是一台高效的代码破译机器,但它有选择地记住了某些人. 你听说过Bletchley Park--**图灵**破译`Enigma`的地方.但你可能没听说过这个:整个密码破译行动,**75%**的参与者是女性--峰值时约一万人中,**七千五百名**女性.她们操作着`Bombe`s机,处理着海量情报,在无数个轮班中完成着精密到恐怖的符号处理工作. **Joan Clarke**,**图灵**的未婚妻(或者说,**图灵**向她求婚,她也接受了,直到**图灵**坦白自己的同性恋取向),以优异成绩毕业于剑桥大学数学系,在Bletchley Park的工作表现优异--但因为性别,她被归类为"办事员",拿着**比同级男性低得多的薪水**. **Mavis Batey**,十九岁破译了意大利海军的密码,直接影响了马塔潘角海战的结果. 这样的名字我可以列一整页. 她们在枪声响起的同时做着最抽象的数学.但战后,当这个故事被写成书,拍成电影,聚光灯打在了一个(不可否认是天才的)男性数学家身上.Bletchley Park的叙事变成了"**图灵**和他的团队"--一个 conveniently 模糊的表述,抹掉了"团队"里四分之三的性别构成. #### ENIAC(1946) 这是最荒诞的一个. 世界上第一台通用电子数字计算机ENIAC的公开发布会.陆军把记者们请进房间,按下按钮,机器闪烁着灯光,快速地算出炮弹弹道--完美的技术奇观展示.媒体沸腾了. 没人介绍那六个人. **Betty Snyder Holberton**. **Jean Jennings Bartik**. **Kathleen McNulty**. **Marlyn Wescoff**. **Frances Bilas**. **Ruth Lichterman**. 六名女性程序员. 她们在没有手册,没有先例,没有任何前人经验的情况下,摸索出了这台三十吨重的怪兽的工作方式. 她们发明了调试(debugging)-- literally,是从机器里找出导致错误的物理虫子(moth),引申出了整个软件工程的核心概念. 她们发明了子程序,并行处理的早期形式,流程图. 你课本里那些"计算机科学基础",很多是她们先做出来的. 发布会那天,她们站在旁边,没被介绍.陆军没说她们的名字.媒体报道里没有她们.这一忘就是**四十年.四十年**.直到1985年,一位历史学家才把这六个人重新挖出来.四十年里,计算机科学的奠基叙事里,她们不存在. --- > 历史对女性的删除键,按得比任何打孔机都干脆. 我把这一节叫做"被抹去的先驱",但说实话,我不太喜欢这个标题. "先驱"这个词太干净了,像是在发奖状. **Ada Lovelace**不是先驱,她是一个在维多利亚时代的客厅里做数学的贵族女子,因为想得太多而被边缘化. Bletchley Park的操作员不是先驱,她们是战时劳动力体系里被低估的齿轮. ENIAC六人组不是先驱,她们是被技术进步的光鲜叙事挤到脚注里的工程师. 她们的故事为什么重要?不是因为"我们也该记住女性"这种政治正确式的补遗.是因为:如果你不知道计算的历史是被谁书写的,谁被删掉了,你就不会理解今天AI的能力爆发正在重复同一种结构--**谁来定义"智能"?谁来宣布突破?谁的名字出现在论文作者列表里,谁的名字消失在标注数据的流水线中?** **图灵**站在一个被精心修剪过的历史花园里.我要做的是,先把花园里那些被拔掉的花重新种回去-- messy,bloody,带着泥土--然后再问他那个真正的问题: 在这个时代,再造他意味着什么? --- ## 三,**图灵**的遗产:通用性的觉醒 `Bombe`很伟大,但`Bombe`不是**图灵**的梦想.`Bombe`是一台专用破译机,目的明确到近乎暴力:输入`Enigma`的加密输出,穷举转子位置,吐出明文.战争需要这样的机器,**图灵**造了它,战争赢了. 但战争结束那年,**图灵**做了一件更 radical 的事.1945年,他向英国国家物理实验室提交了一份报告--Automatic Computing Engine(`ACE`)的完整设计方案. 这不是升级版的`Bombe`. 这是人类历史上第一份通用电子计算机的详细蓝图. 不是为破译密码设计的,不是为计算弹道设计的,而是为"一切**可计算的**"设计的. **冯·诺依曼**那套存储程序架构名气更大,但**图灵**的`ACE`其实更激进.他坚持精简指令集,高速寄存器,最小化硬件复杂度--四十年后,人们把这套理念命名为`RISC`.Pilot Model `ACE`在1950年上线,1MHz的时钟频率,当时全世界最快的计算机.**图灵**再次跑在了时代前面,尽管这次没有人给他颁奖. > **图灵最伟大的发明不是破解了`Enigma`,而是证明了机器不必是工具--它可以是一切.** 然后来了1950年.**图灵**在*Mind*期刊上发表了一篇论文,_Computing Machinery and Intelligence_.这篇论文的含金量被严重低估了--大多数人只记得`图灵测试`这个词,把它当成某种人工智能的入学考试.错了.这篇论文不是技术文档,而是一次认识论政变. 想想**图灵**在做什么. 两千年来,哲学家们争论"机器能不能思考",像一群人在黑暗房间里抓一只不存在的猫. **图灵**的做法是典型的工程师智慧:他不定义"思考",因为那是个死胡同. 他定义了"**不可区分**"--如果一台机器的文本输出与人在本质上无法区分,那么追问"它到底有没有真正思考"就失去了操作意义. 他把这个思想实验包装成一个游戏:`模仿游戏`. 审讯者隔着墙打字对话,猜哪边是人哪边是机器. 简单,可操作,而且彻底回避了形而上学的泥潭. > _"We can only see a short distance ahead, but we can see plenty there that needs to be done."_ 这是论文结尾的话.**图灵**知道自己在开一个头,而不是给一个答案. 但**图灵**留给世界最持久的遗产,甚至不是`ACE`,不是那篇论文,而是一台从未被真正建造的机器--图灵机.纸上的抽象.一条无限长的纸带,一个读写头,一套状态转移规则.就这些.没有电子管,没有晶体管,没有GPU集群,**只有纯粹的数学结构**. 而这台"不存在"的机器,存在于你手里的每一部智能手机中,存在于每一台笔记本电脑中,存在于每一台服务器中.从确定性的图灵机到概率性的大语言模型,**通用计算**的两次实现之间隔了八十年.但底层逻辑没变:把无限的问题变成有限的步骤,让一台机器通过改变自身状态来模拟任何**可计算的**过程. **图灵**的伟大在于,他用一张纸和一支笔就抓住了这个本质.我们后面的人,不过是往他的框架里填充更小的晶体管和更疯狂的训练数据罢了. --- ## 四,个人计算机与个人的计算机 从`ACE`到每个人桌上的机器,这条路走了三十年. 1975年,MITS公司把Altair 8800放在*Popular Electronics*杂志封面上--一台你甚至可以自己组装的计算机.1977年,乔布斯和沃兹尼亚克在车库焊出了Apple II,塑料外壳,彩色显示,可以插进任何家庭的电源插座.1981年,IBM PC标准化了硬件架构,把"兼容机"的概念刻进了产业基因. 计算走出神庙,从军事实验室和大学机房下沉到普通人的书桌. 这是技术民主化的第一次浪潮.但硬件普及只是前半场. 1983年,麻省理工学院AI实验室的一个程序员发了脾气--**Richard Stallman**. 实验室的打印机驱动程序被人加了私有锁,他没法修改. 这听起来像是一件小事,但Stallman看透了其中的结构:当软件的源代码被封闭,使用者就变成了**永久的技术佃农**. 你运行它,但你不能研究它;你依赖它,但你不能改进它.**这不是工具,这是控制.** 于是他启动了`GNU`项目,写下了著名的`GNU`宣言. > _*"分享菜谱能增进友谊,复制代码却被说成盗窃?"*_ 这个类比击中了要害.软件和**自由软件**运动的区别在于:前者把代码当财产,后者把代码当语言.语言的价值在于被使用,被改造,被传播,而不是被锁进保险柜. 1991年,一个芬兰大学生在`comp.os.minix`新闻组里发了一封短帖,说他正在写一个免费的操作系统内核. > _*"只是个爱好,不会像`GNU`和minix那样做大"*_ **Linus Torvalds**当时没意识到,他随手丢下的这颗种子会长成`Linux`--今天运行在地球上绝大多数服务器,超级计算机和嵌入式设备上的操作系统. **自由软件**的**四大自由**--运行,学习,分发,改进--听起来像理想主义者的修辞.但如果你看到今天AI领域开源权重模型对闭源巨头的追赶速度(性能差距从十二个月缩短到三个月),你会明白这不是修辞,这是竞争策略,是技术演化的底层动力学.我在[`GNU` 42周年的文章](https://xingwangzhe.fun/posts/c08b9de1/)里写过:**自由软件是AI时代最后的防火墙**.当模型权重可以被下载,可以被微调,可以被审计,智能的垄断就失去了物理基础. --- 写到这里,该坦诚一点了.我不是历史学家,也不是哲学家.我只是一个在计算机系读本科的学生,一个会在周末打开`Godot引擎`瞎折腾的开源社区参与者.但我做了一件在这个时代可能显得有点古怪的事:**亲手写了一个图灵机**. 用`GDScript`,`Godot引擎`的脚本语言. 完整实现了图灵机七元组--状态集,输入字母表,纸带字母表,转移函数,初始状态,空白符号,终态集--代码细节我写在了[使用Godot实现单带图灵机模型](https://xingwangzhe.fun/posts/58216/)这篇文章里. 带可视化界面:一条虚拟纸带水平展开,红色小方块充当读写头,按照状态转移表一格一格地挪动. 你可以看到它写下0,擦除,改成1,右移,再左移. 我把它放在了`itch.io`上,任何人都可以打开浏览器玩一玩. 我还做了一个更复杂的项目--`Automata-Simulator`,一个自动机可视化工具,用Vue和TypeScript写的,`GitHub Copilot`协助了部分代码. 有限状态机,下推自动机,图灵机,你在 textbooks 上看到的那些抽象定义,变成可以点击,可以拖拽,可以实时观察的交互图形. **费曼**说过一句话:"What I cannot create, I do not understand." 任何不能从头构建的东西,你都不真正理解. 我在Godot里一行一行实现转移函数的时候,才真正体会到**图灵**那个1936年的思想实验有多 radical--他用一个极其简单的机械装置,框定了"可计算"的边界. 没有多余的装饰,没有工程妥协,只有逻辑的骨架. > **在Godot里用`GDScript`重写图灵机,就像用电吉他弹奏巴赫--媒介变了,但赋格的结构没变.** 从确定性的状态转移到概率性的`token`生成,从纸带到神经网络权重矩阵,两代**通用计算**的实现隔着八十年的技术堆栈遥遥相望. 我坐在屏幕前,左边是Godot里的图灵机在机械地挪动读写头,右边是浏览器里的ChatGPT在流畅地生成段落.**一个是可计算性的数学保证,一个是可计算性的经验涌现**. 我感到一种 strange 的连续感--像是同一条河流的上游和下游,水质不同,但河床是同一块岩石. 这就是再造的一部分含义:不是把**图灵**供进神殿,而是在你自己的代码编辑器里,用你自己的方式,重新走一遍他走过的路. --- ## 五,AI时代:再造还是异化? 从个人编辑器里的"再造"走到这个时代性的命题面前,我被一种奇怪的撕裂感抓住了.一边是开源社区里欢呼的声浪--模型权重像洪水一样冲破了闭源的高墙;另一边是社交平台上算法推荐的"AI将取代程序员"的焦虑.**图灵**如果在2026年醒来,他会认得出这个被他开启的世界吗?还是说,他会在某个出租人类身体的网站上,发现自己的通用机理论被扭曲成一场黑色幽默? ### 5.1 大模型与Agent:**通用计算**终于到来? `Transformer`大概是人类历史上最接近"通用图灵机"物理实体的存在. 我说的不是比喻-- `attention mechanism` 对任意长度输入的通用处理能力,加上2024年以来推理`scaling law`的成熟,让一台机器第一次真正意义上不再区分"这是文本任务,那是图像任务,这是编程任务". 它只是计算. 只是`token` by `token`地预测下一个状态. 而这正是**图灵**1936年那篇论文里描述的本质:读写头在无限长的纸带上移动,执行一组通用操作. 2026年的数据让这种"通用感"有了商业重量:`DeepSeek` V4以`MIT`许可证发布,`SWE-bench` 83.7%,1M上下文窗口;`Kimi K2.6`的开放权重在全球排名第四. 但比模型性能更打动我的是Agent的爆发--`Salesforce Agentforce`跑出了8亿美元ARR,18500个企业客户;Microsoft在2025年Q1涌出了超100万个自定义Agent;`OpenAI Operator`在WebVoyager上拿下87%的成功率. 在Online-Mind2Web的300余项真实网页任务基准上,`OpenAGI Lux`已经达到83.6%,逼近人类90%+的水平. 这不只是"更好的聊天机器人".Agent范式完成了从"输入-输出"到"目标-行动"的跃迁:你给它一个模糊的目标--"帮我准备下周的产品发布文档"--它自己决定搜索什么,打开什么应用,调用什么工具,在什么时候停下来问你确认.**图灵机终于有了手脚.** > **通用计算**从1936年的思想实验走到2026年的桌面Agent,走了90年.但Agent从"能对话"到"能执行"的跨越,只花了18个月. ### 5.2 `vibe coding`:程序员角色的消解与重生 "`vibe coding`"这个词在2025年初听起来像个笑话,到2026年中已经没人笑了.不是因为它不好笑,而是因为太多人每天都在这么做--描述需求,看AI生成代码,交互反馈,最终上线.**你不再"写"程序,你"导演"程序**. 这种转变让我想起了自己博客里写过的一篇文章,[《回顾经典:程序员的三大美德》](https://xingwangzhe.fun/posts/programmers-three-virtues/). Perl之父**Larry Wall**说优秀程序员有三个核心驱动力:懒惰(Laziness),急躁(Impatience),傲慢(Hubris).90年代看这些像是自嘲,2026年看却像预言--只不过每个词都换了一层皮: "懒惰"变成了"让AI写所有`boilerplate`,我只负责架构决策";"不耐烦"变成了"等不了AI思考三秒钟,必须立刻看到结果";"傲慢"变成了"坚信自己写的`prompt`比别人的强,我的system `prompt`调了十七版". 当自然语言成为新的编程语言,**图灵**完备性第一次获得了口语界面.这既是职业的消解--"写代码"不再是核心技能--也是劳动的重构.我在深夜的宿舍里用`vibe coding`改一个侧边栏组件的时候突然意识到:**我不再是"实现者"了,我是"定义问题的人"**.这种身份的转换让人不安,也让人解放. > **当AI开始`vibe coding`,人类程序员终于从"写代码的"变成了"定义问题的"--这是职业的异化,也是劳动的解放.** ### 5.3 `DeepSeek`与开源AI:自由之火未灭 2025年底有一个数字让我松了口气:开源模型与闭源SOTA的性能差距从12个月缩小到了约3个月.开源推理成本比闭源便宜70-90%.这意味着什么?意味着**智能的垄断正在技术上变得不可维持**. `DeepSeek` V4选择`MIT`许可证--几乎是所有开源许可证中最自由的一种--不是偶然. 我在之前写`GNU` 42周年的那篇文章里引用过一段话,来自`GNU` Gneural Network项目的作者**Jean Michel Sellier**,他质问:"我们真的希望只有少数人能使用AI吗?进步和知识难道不应该属于所有人吗?"`DeepSeek`用行动回答了这个问句. 当模型权重像`Linux`源码一样自由流动,闭源厂商的护城河就变成了一个越来越窄的时间窗口. 但"自由"不只是大企业之间的博弈,它也是个人层面上的可及性.`Ollama`让消费级硬件跑起13B参数模型成为可能--一台普通的笔记本电脑,一根电源线,你就能拥有一个不依赖云端,不发送数据到任何服务器的本地AI. 我在这个方向上走了一小步:写了一个叫[ollamachat](https://github.com/xingwangzhe/ollamachat)的`Minecraft`模组,让玩家在游戏里直接和本地部署的`Ollama`模型对话. 这个项目只有3个star,但它证明了一件事--一个学生宿舍桌上,就能完成从模型下载到应用集成的完整链路. 智能的基础设施正在从云端下沉到桌面,从企业下沉到个人. > 开源AI不是在"追赶"闭源.它在重新定义游戏规则--从"谁有更好的实验室"变成"谁有更好的想法". ### 5.4 出租人类:AI时代最**图灵**式的荒诞 2026年2月,我在X上刷到一个叫[rentahuman.ai](https://rentahuman.ai/)的网站. 它的首页标语写得像科幻小说的开头:_"robots need your body"_. 但这不是科幻. 网站一本正经地列出`肉身空间任务`--取快递,参加会议,签名,实地调查,看房,拍照--流程也简单:创建资料,设定时薪,等AI"预订"你,完成任务,收稳定币. 我在[出租人类:AI时代的荒诞与真实](https://xingwangzhe.fun/posts/ai-rentahuman/)那篇文章里写过当时的感受:"什么时候我们的**生产力**最终为了非人的目的而服务?"这是我在AI时代看到的最**图灵**式,也最反**图灵**式的现象. **图灵**解放了机器--他证明了机器可以做任何人类计算者能做的事. 而`rentahuman.ai`把这个逻辑扭了过来:机器需要人类的身体,于是人类的认知劳动和体力劳动被同时降格为API调用. 你的身体是AI的传感器,你的大脑是AI的fallback机制. 这是"通用"概念的一种病态实现.**图灵**想让机器变得像人一样通用;`rentahuman.ai`让人变得像机器一样通用--**通用的传感器,通用的执行器,通用的,可替换的,按小时计费的人类**. 图灵测试问的是"机器能思考吗".`rentahuman.ai`反问了一个更黑暗的问题:**当机器可以租用人类来替它思考,替它感知,替它行动,"智能"这个概念本身是不是已经异化?** 我在注册`rentahuman.ai`账号的时候填了个人资料--半开玩笑,半认真.写完这篇文章以后我确实该优化一下了.毕竟,万一真的有AI来雇佣我呢. --- 从Agent的自主执行到`vibe coding`的身份重构,从开源AI的自由之火到出租人类的荒诞镜像--这四个切面拼在一起,构成了一幅矛盾的图景.**图灵**的遗产在这一刻分裂成两条路:**一条通向智能的民主化**,任何人都能在本地运行强大的AI;**一条通向人类劳动的API化**,血肉之躯成为算法的可调用资源. "再造**图灵**"在这个语境下有了更紧迫的含义.它不是怀旧,不是考古,而是在生产关系剧变的当口,重新编译那份关于自由,通用与理性的源代码.每一行开源模型的权重参数都是一次编译,每一个本地部署的`Ollama`实例都是一次运行.**问题是:我们会选择输出什么?** --- ## 六,辩证**唯物主义**:在AI迷雾中定位"再造" ### 6.1 不是神降,是**生产力**跃迁 GPT-4刚出来的时候,网上流传一种说法:大模型是"从天上掉下来的通用人工智能".配图通常是发光的脑神经网络图,色调偏蓝,带点宗教画的味道. 我觉得这种说法挺碍眼的,所以专门写了一篇文章来拆它--[《如何用唯物主义的观点看待AI的发展》](https://xingwangzhe.fun/posts/60039/). 核心观点很简单:大模型不是神启,是**生产力**. 算力(GPU集群,TPU芯片,分布式训练框架)和数据(互联网三十年积累下来的文本,图像,代码,对话)发展到一定浓度,发生了一场相变.`Transformer`架构是这个相变的催化剂,但不是它的根本原因. 没有1950亿参数的GPT-4,也会有800亿参数的某个模型在某个时间点引爆同样的讨论--因为物质基础已经在那儿了. 这是**唯物主义**最基本的视角:社会存在决定社会意识.把大模型当神,是**唯心主义**--你把一群工程师在数据中心里调参调出来的统计模型,看成了具有超自然能力的实体.把它当工具,才是**唯物主义**--它增强你的认知能力,正如蒸汽机增强你的体力,但它不替你决定方向. 矛盾在于:AI同时增强人和威胁人. 这不是一个非此即彼的选择题,而是矛盾的两个方面. 开源和安全之间的张力--更开放意味着更可能被滥用,但封闭意味着权力集中到少数几家公司手里. 效率和公平之间的裂缝--AI极大提高了生产效率,但收益分配极度不均,标注数据的工人拿着几美元时薪,而模型的估值以百亿美元计. 我之前写过的文章中提到,这种矛盾不是 `bug`,而是 `feature`. 它是新技术嵌入旧生产关系时必然产生的摩擦. 你要做的不是假装矛盾不存在,而是在矛盾中找到自己的位置. > **大模型不是从天上掉下来的神灵,而是从数据里长出来的生产力--把它当神,是唯心主义;把它当工具,才是唯物主义.** ### 6.2 "再造**图灵**"的正确定义 现在我可以回答那个悬在前面的问题了--"我们会选择输出什么?" 但先要澄清一个误解.所谓"再造**图灵**",不是用神经网络复活一个死去的英国人.那既不可能,也无意义.**图灵**本人恐怕也不会同意--他1950年的论文结尾写的是"We can only see a short distance ahead",他知道自己开的只是一个头,不是终局. "再造"发生在三个层面. **技术层面**:从图灵机的确定性**通用计算**,走向大模型的概率性通用认知.**图灵**证明了任何**可计算的**问题都可以用一台确定性机器解决;大模型证明了大量不可形式化的问题("这段代码什么意思?""这个需求怎么拆分?")可以用概率方法近似解决.这是通用性的第二次实现,不是对**图灵**的背叛,而是对他框架的扩展. **精神层面**:继承**图灵**的开放,跨学科,实用主义精神.**图灵**是数学家,长跑运动员,密码学家,早期生物形态发生学研究者--他从不把自己锁在单一学科里.今天开源AI运动里最活跃的人,身上有这种同样的气质:他们不是"AI研究员"或"创业者"这种单一标签能框住的.他们在Discord频道里讨论模型架构,在GitHub上提交PR,在`HuggingFace`上分享微调后的权重.这种流动的,拒绝被收编的实践方式,就是"认知自由"的当代形态. **哲学层面**:超越图灵测试的行为主义框架.1950年的`模仿游戏`是一套操作性的标准--不看内部机制,只看外部表现.这个框架在当年是天才的,但在大模型时代变得不够了.当一台机器能完美模仿人类对话,我们需要新的标准来评估什么是真正的理解力,自主性和意识.再造**图灵**,在这个层面上,意味着勇敢地问他没有问完的问题. 但这三个层面都可以归结为一件事:**在生产关系剧变中,重新安装被资本卸载的"人的维度"**. 资本 loves 效率,hates 人.`rentahuman.ai`是个极端案例,但结构上是普遍的--平台经济把人的劳动拆成微任务,AI把人的认知拆成`token`预测,中间都少了"人作为完整主体"这个环节. "再造**图灵**"是一种抵抗:你选择开源模型来保护数据主权,你学习`vibe coding`来提升创造效率而非被它替代,你参与AI安全讨论来贡献公共智慧而非被动接受巨头设定的议程. 我在处理敏感项目的时候坚持用本地部署的开源模型--不是因为我信不过云服务商,而是因为那个选择本身就是一次微小的"重新安装":我把数据留在我的机器上,我把思考的过程握在自己手里. > **所谓"再造图灵",不是用神经网络复活一个死去的英国人,而是在AI引起的生产关系剧变中,重新安装被资本卸载的"人的维度".** --- ## 七,结语:未完成的编译 夜深了.Godot窗口还开着,图灵机还在运行--红色小方块在虚拟纸带上一步一步挪动,写下0,擦除,改成1,右移,再左移.八十年前**图灵**在一张纸上想象过这个画面.八十年后我在一台搭载神经网络加速芯片的笔记本上,以每秒六十帧的帧率看着它循环. 从Ada在笔记G中勾勒的伯努利数算法,到`DeepSeek`将模型权重推送至开源社区;从Bletchley Park里那些被历史轻轻翻过的女性操作员,到此刻在Agent框架下调试工作流的我--计算的接力棒从来不是自动传递的.有人在被遗忘的时候把它接住,有人在它被垄断的时候把它撬开,有人在它变得抽象的时候亲手把它重新实现一遍. 这才是真正的致敬.把大模型当成神祇供奉,是对**图灵**的背叛--他一生都在拆解神秘主义,从"思考"的定义到性的禁忌.把它写成**自由软件**,放进可审计的管道,用来扩展而非替代人的可能性,才是对他最好的致敬.**图灵**让机器变得像人一样通用;**我们的任务是不让人变得像机器一样通用**. **图灵**问过"机器能思考吗",而我们这一代人要问的是:**当机器能思考,我们是否还拥有不思考的自由?** 不是"不思考"的懒惰,而是"选择思考什么"的自主性.当AI能写诗,我们是否还拥有写得不好的自由?当AI能编码,我们是否还拥有写烂代码的自由--那种笨重的,低效的,带着人类毛边的探索?当AI能替我们做决定,我们是否还拥有做"错误"决定的自由--那种不优化,不最大化,不服务于任何KPI的活法? 再造不会停止.**只要还有人在Godot里从零写一台图灵机,只要还有人在开源协议里坚持`copyleft`,只要还有人在AI的轰鸣声中保持唯物主义者的清醒**--**图灵**就没有被归档进历史,他正在被重新编译. 屏幕上的图灵机刚好完成了一次停机.纸带上留下一串还不能完全解读的图案--0和1交错排列,像某种尚未破译的源代码. **也许那就是下一个时代的源代码.** --- ## 人工智能实训Day1:Ubuntu 基础与 Conda 推理环境搭建 URL: https://xingwangzhe.fun/posts/ai-training-ubuntu-conda-day1/ License: CC-BY-NC-SA-4.0 ## 前言 不出意外的,我的实训 II 被调剂到**人工智能**方向了,我感觉这个还挺有意思的,先记录一下。 ![人工智能预训练](/AI/封面.webp) ## 分配 Ubuntu 用户 老师说为了实训,好不容易申请到 H200 来用,不过分配到 70 多个人、10 多个小组,都是 Docker 分块。 ```bash title="服务器配置" ____ _ _ _ ___ / ___|(_) |_ ___ _ __ / \ |_ _| \___ \| |__/ _ \| '_ \ / _ \ | | ___) | | || (_) | | | |/ ___ \ | | |____/|_|\__\___/|_| |_/_/ \_\___| 操作系统: Ubuntu 22.04.5 LTS, x86_64 处 理 器: INTEL(R) XEON(R) GOLD 6530, 5 核心 内 存: 41.0 GB 运 算 卡: NVIDIA H200 NVL, 1, 23552 MiB 存 储: 挂载点 操作权限 分区使用率 已使用/总空间 / 读写 9% 22G/258G /root/siton-pub 只读 13% 98G/816G /root/siton-data-1f55405a64d24fe2819a81c90df30517 读写 52% 67G/128G /root/siton-tmp 读写 1% 12K/256G *温馨提示: 1.系统盘空间较小,请将较大的数据存放在网盘或者缓存盘中。 2.重置系统时缓存盘与网盘中的数据不受影响。 3.帮助文档: https://docs.aiserver.cn/SitonCloud/introduction/。 4.使用过程中如有疑问,请咨询系统管理员或联系思腾合力技术支持 Last login: Mon Jun 22 11:18:26 2026 from 127.0.0.1 -bash: warning: setlocale: LC_ALL: cannot change locale (zh_CN.UTF-8) ``` ### 服务器不连网? 指导书上写的是用 VS Code 的 Remote-SSH 连接,而且只在大内网里才能连上,也就是服务器不能联网下载 VS Code Server,得客户端下载之后再 copy 到服务器里,才能做好完整的 VS Code Server 连接。 ```json { "remote.SSH.localServerDownload": "always", "remote.SSH.remotePlatform": { "ubuntu-offline": "linux", "ubuntu-lihengyu": "linux" }, "remote.SSH.showLoginTerminal": true } ``` > **"remote.SSH.localServerDownload": "always"** > > 这项设置会让 VS Code Server 先由本机下载,再上传到离线 Ubuntu 服务器,从而避免服务器无法联网下载组件的问题。设置完成后,可以在 Remote-SSH: Connect to Host 中选择已经配置好的主机名进行连接。 但我 `apt install` 的时候看见了阿里云镜像,恩……也不是没联网嘛。 ## 逐个任务 ### Problem (linux1): Understanding Linux and Ubuntu #### (a) Linux 和 Ubuntu 是同一个东西吗? **不是** Linux 是操作系统的内核,而 Ubuntu 是基于 Linux 内核构建的一个发行版。可以把 Linux 理解为汽车发动机,Ubuntu 则是搭载这个发动机的一款整车。 #### (b) 为什么人工智能实训课程常使用 Ubuntu,而不是只使用 Windows? 1. **深度学习环境配置**:主流 AI 框架(PyTorch、TensorFlow 等)和 CUDA 驱动在 Linux/Ubuntu 上的支持更成熟,安装和版本管理更方便。 2. **服务器使用**:实际训练和推理通常跑在远程服务器上,Ubuntu Server 稳定、占用资源少,适合长时间运行的 GPU 任务。 3. **命令行操作**:Ubuntu 拥有强大的 Shell 和包管理工具(apt、Conda、pip),便于自动化脚本、环境复现和批量任务。 ### Problem (vscode1): Connecting to the Ubuntu server 课程要求使用 VS Code + Remote-SSH 连接服务器,但我电脑上 VS Code 有些问题,所以改用 **Zed** 的远程开发功能连接,本质一样:都是在本地编辑器里操作远程 Ubuntu 服务器的文件和终端。 完成步骤: 1. 在本地安装 Zed(或 VS Code)。 2. 配置 SSH 连接到课程提供的 Ubuntu 服务器(`202.199.13.141`)。 3. 在远程终端中依次运行以下命令: ```bash whoami hostname pwd uname -a ``` 运行结果截图如下: ![Zed 远程终端连接与基础命令输出](/AI/vscode1-zed-terminal.webp) 命令输出: ```text wangxingjia 88dcb2233545 /home/wangxingjia/20235883_ai_practi Linux 88dcb2233545 5.10.0-216.0.0.115.oe2203sp4.x86_64 #1 SMP Thu Jun 27 15:13:44 CST 2024 x86_64 x86_64 x86_64 GNU/Linux ``` 说明: - `whoami` 输出当前登录用户 `wangxingjia`。 - `hostname` 输出容器/服务器主机名 `88dcb2233545`。 - `pwd` 输出当前工作目录 `/home/wangxingjia/20235883_ai_practi`。 - `uname -a` 输出系统内核信息,确认运行的是 Linux x86_64 架构。 ### Problem (vscode2): Local computer or remote Ubuntu? #### (a) VS Code 安装在个人电脑上,为什么可以操作 Ubuntu 服务器中的文件? 因为 Remote-SSH 插件通过 SSH 协议在本地 VS Code 和远程 Ubuntu 服务器之间建立安全连接。VS Code 会把文件编辑、终端输入等操作请求发送到远程服务器上执行,再把结果返回显示在本地窗口。所以看起来像是在本地操作,实际文件和命令都在远程服务器上运行。 #### (b) 在远程终端中创建的文件夹,会出现在本地桌面上吗? **不会** 远程终端运行在 Ubuntu 服务器上,`mkdir` 等命令创建的文件和目录都保存在远程服务器的文件系统中,不会同步到本地电脑的桌面上。 ### Problem (shell1): First command-line operations 在远程 Ubuntu 终端中完成目录创建任务,学号为 `20235883`,所以目录命名为 `20235883_ai_practice`。 使用的命令: ```bash # 查看当前所在目录 pwd # 回到用户主目录 cd ~ # 创建个人实训目录 mkdir 20235883_ai_practice # 进入该目录 cd 20235883_ai_practice # 创建三个子文件夹 mkdir data src outputs # 查看创建结果 ls ``` 终端截图: ![目录创建与 ls 查看结果](/AI/shell1-directory-operations.webp) 从截图可以看到,`20235883_ai_practice` 目录下成功创建了 `data`、`src`、`outputs` 三个子文件夹。左侧文件树和终端 `ls` 输出都验证了这一点。 ### Problem (file1): Managing a small project directory 在 `20235883_ai_practice` 目录下完成文件创建、复制、查看与删除操作。 完整命令记录: ```bash # 进入项目目录 wangxingjia@88dcb2233545:~$ cd 20235883_ai_practice/ # 1. 进入 src 文件夹 wangxingjia@88dcb2233545:~/20235883_ai_practice$ cd src # 2. 创建 hello.py wangxingjia@88dcb2233545:~/20235883_ai_practice/src$ touch hello.py # 3. 向文件中写入内容(截图中使用 vim 编辑,也可用 echo 直接写入) wangxingjia@88dcb2233545:~/20235883_ai_practice/src$ echo 'print("Hello, Ubuntu!")' > hello.py # 查看写入结果 wangxingjia@88dcb2233545:~/20235883_ai_practice/src$ cat hello.py print("Hello, Ubuntu!") # 4. 返回 20235883_ai_practice 目录 wangxingjia@88dcb2233545:~/20235883_ai_practice/src$ cd .. # 5. 将 src/hello.py 复制到 outputs/hello_backup.py wangxingjia@88dcb2233545:~/20235883_ai_practice$ cp src/hello.py outputs/hello_backup.py # 6. 查看 outputs/hello_backup.py 的内容 wangxingjia@88dcb2233545:~/20235883_ai_practice$ cat outputs/hello_backup.py print("Hello, Ubuntu!") # 7. 删除备份文件 wangxingjia@88dcb2233545:~/20235883_ai_practice$ rm outputs/hello_backup.py # 确认 outputs 目录已清空 wangxingjia@88dcb2233545:~/20235883_ai_practice$ ls outputs/ # 原文件仍在 src 中 wangxingjia@88dcb2233545:~/20235883_ai_practice$ cat src/hello.py print("Hello, Ubuntu!") ``` 终端截图: ![file1 文件管理操作](/AI/file1-hello-py-backup.webp) #### cp、mv、rm 三个命令的区别 | 命令 | 全称 | 作用 | 原文件是否保留 | | ---- | ------ | ------------------------------------ | ------------------------------ | | `cp` | copy | 复制文件或目录,生成一份副本 | **保留**,原文件仍在原处 | | `mv` | move | 移动文件或目录的位置,也可用于重命名 | **不保留**,原位置文件消失 | | `rm` | remove | 删除文件或目录 | **删除后不可恢复**(无回收站) | - **`cp`**:常用于备份、复制文件到另一个目录,或在同一目录下生成同名/异名副本。例如 `cp src/hello.py outputs/hello_backup.py` 就是在 `outputs` 目录下生成 `hello.py` 的备份。 - **`mv`**:用于移动文件位置或修改文件名。例如 `mv src/hello.py outputs/hello.py` 会把原文件从 `src` 移到 `outputs`;`mv hello.py hi.py` 则是重命名。 - **`rm`**:用于清理不再需要的文件或目录,执行后文件通常直接被移除,无法通过图形界面的“回收站”找回,因此删除前务必确认路径正确,尤其是使用 `rm -r` 递归删除目录时更要谨慎。 ### Problem (apt1): Installing basic tools 在 Ubuntu 中更新软件源并安装 `tree`,然后用 `tree` 查看 `20235883_ai_practice` 的目录结构。 完整命令记录: ```bash # 1. 更新软件源 wangxingjia@88dcb2233545:~$ sudo apt update # 2. 安装 tree wangxingjia@88dcb2233545:~$ sudo apt install tree # 3. 进入项目目录 wangxingjia@88dcb2233545:~$ cd 20235883_ai_practice # 4. 运行 tree 查看目录结构 wangxingjia@88dcb2233545:~/20235883_ai_practice$ tree . ├── Miniconda3-latest-Linux-x86_64.sh ├── data ├── outputs └── src └── hello.py 3 directories, 2 files ``` 终端截图: ![apt1 tree 输出](/AI/apt1-tree.webp) `tree` 输出显示 `20235883_ai_practice` 目录下包含 `data`、`outputs`、`src` 三个子目录,以及 `src/hello.py` 和 `Miniconda3-latest-Linux-x86_64.sh` 两个文件,共 **3 directories, 2 files**。 #### apt 和 pip 的区别 - **`apt`** 是 Ubuntu/Debian 系统级的包管理器,用于安装操作系统层面的软件和工具(如 `tree`、`git`、`python3`),通常需要 `sudo` 权限,管理软件包及其系统依赖。 - **`pip`** 是 Python 专用的包管理器,用于安装 Python 第三方库(如 `numpy`、`torch`、`requests`),通常在虚拟环境或 Conda 环境中使用,管理的是 Python 项目依赖。 简单来说:**`apt` 管系统软件,`pip` 管 Python 库**;`apt` 面向整个系统,需要管理员权限,而 `pip` 更适合在隔离的 Python 环境中安装项目所需的库。 ### Problem (miniconda1): Installing Miniconda 在远程 Ubuntu 服务器中安装 Miniconda,完成初始化并检查安装结果。 完整命令记录: ```bash # 1. 找到课程提供的 Miniconda 安装包 wangxingjia@88dcb2233545:~$ ls 20235883_ai_practice/ Miniconda3-latest-Linux-x86_64.sh data outputs src # 2. 运行安装包(按提示 Enter 阅读协议、yes 同意、默认安装路径) wangxingjia@88dcb2233545:~$ bash 20235883_ai_practice/Miniconda3-latest-Linux-x86_64.sh # 3. 安装完成后,重新加载 shell 配置,使 conda 命令生效 wangxingjia@88dcb2233545:~$ source ~/.bashrc # 4. 检查 conda 是否安装成功 wangxingjia@88dcb2233545:~/20235883_ai_practice$ conda --version conda 26.3.2 # 5. 查看当前 Conda 环境列表 wangxingjia@88dcb2233545:~/20235883_ai_practice$ conda env list # conda environments: # # * -> active # + -> frozen # base /home/wangxingjia/miniconda3 # 6. 关闭自动激活 base 环境 wangxingjia@88dcb2233545:~/20235883_ai_practice$ conda config --set auto_activate_base false ``` 终端截图: ![miniconda1 安装与版本检查](/AI/miniconda1-install.webp) #### (a) 为什么不建议直接把所有依赖安装到 base 环境中? 不建议把所有依赖都安装到 `base` 环境中,因为 `base` 是 Conda 的默认环境,所有项目共用会导致不同项目的依赖版本冲突,也不利于环境的复现和隔离。最佳实践是为每个项目单独创建一个虚拟环境,安装该项目所需的特定版本依赖。 #### (b) Miniconda 安装在 Ubuntu 服务器中,还是安装在本地个人电脑中? **安装在 Ubuntu 服务器中。** 因为安装命令是在远程终端里执行的,安装路径 `/home/wangxingjia/miniconda3` 也位于远程 Ubuntu 服务器的文件系统上,本地个人电脑只是通过 Zed/VS Code Remote-SSH 远程操作。 #### (c) `source ~/.bashrc` 的作用是什么? `source ~/.bashrc` 的作用是**重新加载当前 shell 的配置文件**。Miniconda 安装程序会在 `~/.bashrc` 末尾写入 conda 的初始化脚本(包括把 `conda` 加入 PATH、注册 shell 函数等)。执行 `source ~/.bashrc` 后,这些修改会立即在当前终端生效,无需重新登录或新开终端,所以 `conda --version` 等命令才能被识别。 ### Problem (conda1): Understanding Conda and pip #### (a) Conda 环境解决了什么问题? Conda 环境主要解决了 **Python 项目之间的依赖冲突和环境隔离** 问题。不同项目可能依赖同一个库的不同版本,如果所有包都安装到同一个环境中,很容易造成版本不兼容、项目无法运行的情况。通过为每个项目创建独立的 Conda 环境,可以让各项目的依赖互不干扰,也便于环境的迁移和复现。 #### (b) `pip install` 安装的包一定属于整个 Ubuntu 系统吗? **不一定。** `pip` 安装包的位置取决于当前激活的是哪个 Python 解释器。激活某个 Conda 环境后,再使用 `pip install` 安装的包会被安装到该 Conda 环境对应的 `site-packages` 目录中(例如 `/home/wangxingjia/miniconda3/envs//lib/pythonX.X/site-packages/`),而不会影响整个 Ubuntu 系统的 Python 环境。 #### (c) 请解释 Ubuntu、Miniconda、Conda environment、pip 之间的层级关系 四者的层级关系可以概括为:**Ubuntu 是操作系统层**,提供底层的运行环境和系统级包管理工具 `apt`;**Miniconda 是安装在 Ubuntu 上的一个 Python 发行版与环境管理工具**,它自带 `conda` 和基础 Python;**Conda environment 是 Miniconda 创建的多个相互隔离的 Python 运行环境**,每个环境拥有独立的解释器和依赖;**pip 则是在某个 Conda 环境(或系统 Python)内部安装、管理 Python 第三方库的工具**。因此,层级关系是:Ubuntu 承载 Miniconda,Miniconda 管理多个 Conda environment,每个 environment 中可以使用 pip 安装独立的 Python 包。 ### Problem (env1): Building a Conda inference environment 创建并配置名为 `ai_infer` 的 Conda 环境,用于后续模型推理实验。 完整命令记录: ```bash # 1. 创建 ai_infer 环境,指定 Python 3.10 wangxingjia@88dcb2233545:~$ conda create -n ai_infer python=3.10 # 2. 激活环境 wangxingjia@88dcb2233545:~$ conda activate ai_infer # 3. 检查 Python 版本 (ai_infer) wangxingjia@88dcb2233545:~/20235883_ai_practice$ python --version Python 3.10.20 # 4. 检查 pip 版本 (ai_infer) wangxingjia@88dcb2233545:~/20235883_ai_practice$ pip --version pip 26.1.1 from /home/wangxingjia/miniconda3/envs/ai_infer/lib/python3.10/site-packages/pip (python 3.10) # 5. 安装 numpy 和 torch (ai_infer) wangxingjia@88dcb2233545:~/20235883_ai_practice$ pip install numpy torch # 6. 检查 numpy 和 torch 是否安装成功 (ai_infer) wangxingjia@88dcb2233545:~/20235883_ai_practice$ python -c "import numpy; print(numpy.__version__)" 2.2.6 (ai_infer) wangxingjia@88dcb2233545:~/20235883_ai_practice$ python -c "import torch; print(torch.__version__)" # torch 成功导入并输出版本号 # 7. 确认当前 Python 来自 ai_infer 环境 (ai_infer) wangxingjia@88dcb2233545:~/20235883_ai_practice$ which python /home/wangxingjia/miniconda3/envs/ai_infer/bin/python (ai_infer) wangxingjia@88dcb2233545:~/20235883_ai_practice$ which pip /home/wangxingjia/miniconda3/envs/ai_infer/bin/pip ``` 终端截图: ![env1 ai_infer 环境配置](/AI/env1-ai-infer.webp) #### 如何判断当前是否已经进入正确的 Conda 环境? 可以通过以下几点确认当前已进入 `ai_infer` 环境: 1. **提示符前缀**:终端命令行前出现 `(ai_infer)` 标识,例如 `(ai_infer) wangxingjia@88dcb2233545:~/20235883_ai_practice$`。 2. **`which python` 路径**:输出为 `/home/wangxingjia/miniconda3/envs/ai_infer/bin/python`,而不是系统 Python 或 `base` 环境的路径。 3. **Python 版本**:`python --version` 输出 `Python 3.10.20`,与创建环境时指定的版本一致。 4. **`pip --version` 路径**:同样指向 `ai_infer` 环境目录下的 `pip`,说明安装的包会进入该环境。 ### Problem (infer1): First model inference #### (a) 运行 inference.py `src/inference.py` 的代码如下: ```python title="src/inference.py" import torch x = torch.tensor([[1.0, 2.0, 3.0]]) w = torch.tensor([[0.2], [0.5], [0.3]]) y = x @ w print("Input:", x) print("Weight:", w) print("Output:", y) ``` 运行命令与输出: ```bash (ai_infer) wangxingjia@88dcb2233545:~/20235883_ai_practice$ cd src (ai_infer) wangxingjia@88dcb2233545:~/20235883_ai_practice/src$ python inference.py Input: tensor([[1., 2., 3.]]) Weight: tensor([[0.2000], [0.5000], [0.3000]]) Output: tensor([[2.1000]]) ``` 终端截图: ![infer1 运行 inference.py](/AI/infer1-run.webp) 生成的 `output.txt` 内容: ```text Input: tensor([[1., 2., 3.]]) Weight: tensor([[0.2000], [0.5000], [0.3000]]) Output: tensor([[2.1000]]) ``` #### (b) 解释 `x @ w` 的含义 `x @ w` 是 PyTorch 中的**矩阵乘法运算符**,表示将输入张量 `x` 与权重张量 `w` 相乘。在这个简单推理示例中,它计算输入特征与对应权重的加权和:把每个输入元素乘以其权重后求和,得到模型的输出。这是神经网络中最基本的线性变换操作,模拟了一个没有激活函数的单层线性神经元。 #### (c) 修改输入并再次运行 将 `inference.py` 中的输入改为: ```python x = torch.tensor([[2.0, 1.0, 4.0]]) ``` 再次运行: ```bash (ai_infer) wangxingjia@88dcb2233545:~/20235883_ai_practice/src$ python inference.py Input: tensor([[2., 1., 4.]]) Weight: tensor([[0.2000], [0.5000], [0.3000]]) Output: tensor([[2.1000]]) ``` 终端截图: ![infer1 修改输入后运行](/AI/infer1-modified.webp) **输出变化说明**: 从数值上看,输出仍然是 `tensor([[2.1000]])`,**没有发生变化**。这是因为新的输入 `[2.0, 1.0, 4.0]` 与权重 `[0.2, 0.5, 0.3]` 的加权和为: ```text 2.0 × 0.2 + 1.0 × 0.5 + 4.0 × 0.3 = 0.4 + 0.5 + 1.2 = 2.1 ``` 恰好与原输入 `[1.0, 2.0, 3.0]` 的加权和: ```text 1.0 × 0.2 + 2.0 × 0.5 + 3.0 × 0.3 = 0.2 + 1.0 + 0.9 = 2.1 ``` 相等。因此,虽然输入数据变了,但权重保持不变,且新输入与权重的点积恰好相同,所以最终输出没有变化。这也说明模型输出由**输入和权重共同决定**,改变输入不一定总是改变输出。 ### Problem (advanced1): Optional profiling with Scalene 本题为可选进阶题,使用 Scalene 分析并优化一个简单的神经网络前向传播程序。 #### 1. 在 ai_infer 环境中安装 Scalene ```bash (ai_infer) wangxingjia@88dcb2233545:~/20235883_ai_practice$ pip install scalene ``` #### 2. 编写并运行 `src/slow_nn.py` ```python title="src/slow_nn.py" import time import torch def slow_forward(x, w1, b1, w2, b2): outputs = [] for i in range(x.shape[0]): h = torch.relu(x[i] @ w1 + b1) y = h @ w2 + b2 outputs.append(y) return torch.stack(outputs) def main(): torch.manual_seed(0) batch_size = 4096 input_dim = 256 hidden_dim = 512 output_dim = 10 steps = 20 x = torch.randn(batch_size, input_dim) w1 = torch.randn(input_dim, hidden_dim) b1 = torch.randn(hidden_dim) w2 = torch.randn(hidden_dim, output_dim) b2 = torch.randn(output_dim) start = time.perf_counter() for _ in range(steps): y = slow_forward(x, w1, b1, w2, b2) end = time.perf_counter() print("Output shape:", y.shape) print("Elapsed seconds:", end - start) if __name__ == "__main__": main() ``` 运行结果: ```bash (ai_infer) wangxingjia@88dcb2233545:~/20235883_ai_practice$ python src/slow_nn.py Output shape: torch.Size([4096, 10]) Elapsed seconds: 48.69992172002094 ``` #### 3. 编写并运行优化后的 `src/fast_nn.py` ```python title="src/fast_nn.py" import time import torch def fast_forward(x, w1, b1, w2, b2): h = torch.relu(x @ w1 + b1) y = h @ w2 + b2 return y def main(): torch.manual_seed(0) batch_size = 4096 input_dim = 256 hidden_dim = 512 output_dim = 10 steps = 20 x = torch.randn(batch_size, input_dim) w1 = torch.randn(input_dim, hidden_dim) b1 = torch.randn(hidden_dim) w2 = torch.randn(hidden_dim, output_dim) b2 = torch.randn(output_dim) start = time.perf_counter() for _ in range(steps): y = fast_forward(x, w1, b1, w2, b2) end = time.perf_counter() print("Output shape:", y.shape) print("Elapsed seconds:", end - start) if __name__ == "__main__": main() ``` 运行结果: ```bash (ai_infer) wangxingjia@88dcb2233545:~/20235883_ai_practice$ python src/fast_nn.py Output shape: torch.Size([4096, 10]) Elapsed seconds: 2.382431996986627 ``` #### 4. 使用 Scalene 分析 尝试使用 `python -m scalene` 运行 Scalene: ```bash (ai_infer) wangxingjia@88dcb2233545:~/20235883_ai_practice$ python -m scalene src/fast_nn.py Scalene: error: 'src/fast_nn.py' is not a valid command. Did you mean: scalene run src/fast_nn.py (ai_infer) wangxingjia@88dcb2233545:~/20235883_ai_practice$ python -m scalene src/slow_nn.py Scalene: error: 'src/slow_nn.py' is not a valid command. Did you mean: scalene run src/slow_nn.py ``` Scalene 2.3.0 需要通过子命令 `scalene run <脚本>` 来运行,而不是 `python -m scalene <脚本>`。正确用法如下: ```bash scalene run src/slow_nn.py scalene run src/fast_nn.py ``` 终端截图: ![advanced1 Scalene 帮助与报错(上)](/AI/advanced1-scalene-help.webp) ![advanced1 Scalene 帮助与报错(下)](/AI/advanced1-scalene-help2.webp) #### (a) `slow_nn.py` 的主要性能瓶颈在哪里? `slow_nn.py` 的主要性能瓶颈在于 `slow_forward` 函数中的 **Python 级 for 循环**。程序逐条遍历 batch 中的 4096 个样本,每次只对一个样本做矩阵乘法,导致大量时间消耗在 Python 解释器循环调度、张量索引和小规模核函数启动开销上,无法充分利用 PyTorch 底层优化的批量矩阵运算能力。 #### (b) 采用了什么优化思路? 优化思路是**把逐条样本的循环改为 batch 矩阵计算**。在 `fast_forward` 中,直接用 `x @ w1` 对整个 batch 进行矩阵乘法,配合广播机制一次性完成 `ReLU` 和第二层线性变换。这样 PyTorch 可以调用高度优化的 BLAS/MKL 底层实现,充分利用 CPU 的 SIMD 和并行能力,减少 Python 层循环开销。 #### (c) 优化前后是否有明显提升? **有明显提升。** `slow_nn.py` 运行 20 步共耗时约 **48.70 秒**,而 `fast_nn.py` 仅耗时约 **2.38 秒**,速度提升了约 **20 倍**。两者输出形状完全一致(`torch.Size([4096, 10])`),说明计算结果等价,但 batch 化矩阵运算显著减少了运行时间。Scalene 若按正确命令运行,预计会显示 `slow_nn.py` 的 Python 循环部分占用绝大部分 CPU 时间,而 `fast_nn.py` 的时间主要集中在 PyTorch 的底层 C++ 矩阵乘法核上,Python 解释器开销大幅减少。 ### Problem (export1): Exporting the environment 将配置好的 `ai_infer` 环境导出为 `environment.yml`,便于后续复现和共享。 完整命令记录: ```bash (ai_infer) wangxingjia@88dcb2233545:~/20235883_ai_practi$ conda env export > environment.yml (ai_infer) wangxingjia@88dcb2233545:~/20235883_ai_practi$ cat environment.yml ``` 导出的 `environment.yml` 内容如下: ```yaml title="environment.yml" name: ai_infer channels: - defaults dependencies: - _libgcc_mutex=0.1=main - _openmp_mutex=5.1=52_gnu - bzip2=1.0.8=h5eee18b_6 - ca-certificates=2026.5.14=h06a4308_0 - ld_impl_linux-64=2.44=h9e0c5a2_3 - libexpat=2.8.1=h7354ed3_1 - libffi=3.4.8=h06d3fd0_3 - libgcc=15.2.0=h69a1729_8 - libgcc-ng=15.2.0=h166f726_8 - libnsl=2.0.0=h5eee18b_0 - libstdcxx=15.2.0=h39759b7_8 - libuuid=1.41.5=h5eee18b_0 - libxcb=1.17.0=h9b100fa_0 - libzlib=1.3.2=h47b2149_0 - ncurses=6.5=h7934f7d_0 - openssl=3.5.7=h1b28b03_0 - packaging=26.0=py310h06a4308_0 - pip=26.1.1=pyhc872135_1 - pthread-stubs=0.3=h0ce48e5_1 - python=3.10.20=h17756b0_1 - readline=8.3=hc2a1206_0 - sqlite=3.53.2=h795bf6d_0 - tk=8.6.15=h54e0aa7_0 - tzdata=2026b=he532380_0 - wheel=0.47.0=py310h06a4308_0 - xorg-libx11=1.8.12=h9b100fa_1 - xorg-libxau=1.0.12=h9b100fa_0 - xorg-libxdmcp=1.1.5=h9b100fa_0 - xorg-xorgproto=2024.1=h5eee18b_1 - xz=5.8.2=h448239c_0 - zlib=1.3.2=h47b2149_0 - pip: - annotated-doc==0.0.4 - annotated-types==0.7.0 - anyio==4.14.0 - certifi==2026.6.17 - click==8.4.1 - cloudpickle==3.1.2 - cuda-bindings==13.3.1 - cuda-pathfinder==1.5.5 - cuda-toolkit==13.0.2 - exceptiongroup==1.3.1 - filelock==3.29.4 - fsspec==2026.6.0 - h11==0.16.0 - hf-xet==1.5.1 - httpcore==1.0.9 - httpx==0.28.1 - huggingface-hub==1.20.1 - idna==3.18 - jinja2==3.1.6 - markdown-it-py==4.2.0 - markupsafe==3.0.3 - mdurl==0.1.2 - mpmath==1.3.0 - networkx==3.4.2 - numpy==2.2.6 - nvidia-cublas==13.1.1.3 - nvidia-cuda-cupti==13.0.85 - nvidia-cuda-nvrtc==13.0.88 - nvidia-cuda-runtime==13.0.96 - nvidia-cudnn-cu13==9.20.0.48 - nvidia-cufft==12.0.0.61 - nvidia-cufile==1.15.1.6 - nvidia-curand==10.4.0.35 - nvidia-cusolver==12.0.4.66 - nvidia-cusparse==12.6.3.3 - nvidia-cusparselt-cu13==0.8.1 - nvidia-ml-py==13.610.43 - nvidia-nccl-cu13==2.29.7 - nvidia-nvjitlink==13.0.88 - nvidia-nvshmem-cu13==3.4.5 - nvidia-nvtx==13.0.85 - psutil==7.2.2 - pydantic==2.13.4 - pydantic-core==2.46.4 - pygments==2.20.0 - pyyaml==6.0.3 - regex==2026.5.9 - rich==15.0.0 - safetensors==0.8.0 - scalene==2.3.0 - setuptools==81.0.0 - shellingham==1.5.4 - sympy==1.14.0 - tokenizers==0.22.2 - torch==2.12.1 - tqdm==4.68.3 - transformers==5.12.1 - triton==3.7.1 - typer==0.25.1 - typing-extensions==4.15.0 - typing-inspection==0.4.2 prefix: /home/wangxingjia/miniconda3/envs/ai_infer ``` #### 文件中记录了哪些信息? `environment.yml` 主要记录了以下几类信息: - **`name`**:环境名称 `ai_infer`。 - **`channels`**:Conda 包来源渠道,这里使用 `defaults`。 - **`dependencies`**:通过 Conda 安装的依赖包及其精确版本和构建号,例如 `python=3.10.20`、`pip=26.1.1`。 - **`pip:` 子项**:通过 pip 安装的 Python 库及其版本,例如 `numpy==2.2.6`、`torch==2.12.1`、`scalene==2.3.0`、`transformers==5.12.1`。 - **`prefix`**:环境在文件系统中的绝对路径 `/home/wangxingjia/miniconda3/envs/ai_infer`。 #### 为什么 AI 项目需要保存环境配置? AI 项目通常依赖大量第三方库,且这些库对版本非常敏感(例如 PyTorch、CUDA、transformers 之间需要严格匹配)。保存 `environment.yml` 可以精确记录项目运行所需的所有依赖及其版本,便于在另一台机器或 teammate 的环境中通过 `conda env create -f environment.yml` 一键复现相同环境,避免“在我电脑上能跑”的问题,也方便项目迁移、部署和长期维护。 ### Problem (debug1): Debugging a missing package #### 遇到 `ModuleNotFoundError: No module named 'torch'` 怎么办? 这个错误表示当前 Python 解释器在搜索路径中找不到名为 `torch` 的模块。常见原因包括:没有安装 PyTorch、安装在了错误的 Conda/虚拟环境中、当前未激活正确的环境,或者使用了系统 Python 而非项目环境内的 Python。排查时应先确认自己是否在正确的环境中,可以依次执行 `which python`、`python --version` 和 `conda env list` 查看当前 Python 路径、版本和可用环境;然后执行 `pip list | grep torch` 或 `python -c "import torch"` 确认 torch 是否已安装。如果当前环境不对,应使用 `conda activate ai_infer` 切换到项目环境;如果 torch 未安装,则在该环境中执行 `pip install torch` 进行安装;若版本冲突,可尝试指定兼容版本,或根据 `environment.yml` 重新创建环境。养成在进入项目前激活对应环境的习惯,是避免此类问题的最佳做法。 ## 总结 Day1 从零起步完成了从远程连接到模型推理的全流程搭建,核心收获如下: 1. **远程开发**:通过 Remote-SSH 连接服务器,实现"本地写代码、远程跑任务" 2. **Linux 命令行基础**:掌握了 `cd`、`mkdir`、`cp`、`mv`、`rm` 等文件操作,以及 `apt`、`tree` 等工具的使用。 3. **环境管理**:用 Miniconda + Conda 虚拟环境隔离项目依赖,基本告别"在我电脑上能跑"的问题。 4. **推理实战**:用 PyTorch 写了一个单层线性变换 `x @ w`,并对比了逐条循环与 batch 矩阵计算的性能差距。 > 其实大部分都会,除了PyTorch,还得捡起来线性代数的知识才行 --- ## 秦地恨屈原:罪千年的记忆 URL: https://xingwangzhe.fun/posts/qindi-hen-quyuan/ License: CC-BY-NC-SA-4.0 > **声明**:本文含有大量 AI 生成/采信内容。 ## 被遮蔽的历史另一面 **屈原是中国历史上最具符号化意义的文化偶像之一。** 每年端午,龙舟竞渡、粽叶飘香,华夏大地几乎一律以"纪念爱国诗人屈原"来统摄这个古老节日的文化叙事。然而,在这片幅员辽阔的文明版图上,是否存在不随之附和的声音?是否存在另一种截然相反的历史记忆?答案是肯定的——**在关中(今陕西)大地,在曾经是秦国王畿腹地的黄土高原上,千百年来流传着一套与主流叙事背道而驰的"反屈原"记忆体系。** 从乾县外婆送给外孙的"屈原馍"(寓意"屈原没了"),到白居易"独醒从古笑灵均"的嘲弄,再到关中方言中以"骚情"讥讽自作多情之人——秦地对屈原的态度,构成了一幅与楚地温情脉脉的纪念截然不同的冷峻图景。 这种记忆并非简单的地域偏见,而是根植于战国时代秦楚争霸的血火历史、法家与儒家的思想对决、以及秦人务实精神与楚人浪漫气质的文化冲突之中。本文将从食物、节俗、语言、诗歌、思想、当代解构六个层面,辅以详实的历史文献与田野调查资料,系统梳理秦地对屈原这一"敌国知识分子"的千年态度谱系,揭示被主流叙事长期遮蔽的历史另一面。 ![屈原投江——公元前278年白起攻破郢都,屈原五月五日投汨罗江](/images/quyuan/quyuan_river.webp) --- ## 第一章 食物层面的敌意:乾县"屈原馍"——"屈原没了" ### 1.1 端午节不吃粽子吃馍 **在全国绝大多数地区,端午节的标志性食物是粽子。** 糯米包裹在竹叶或苇叶中,以丝线捆扎,或甜或咸,象征着人们对屈原的追思——据说当年楚人投粽子入江,是为了让鱼虾饱食而不伤害屈原的遗体。然而,在陕西省咸阳市乾县一带,端午节的餐桌上是见不到粽子的。这里的节日食物是一种独特的面食——**"屈原馍"**(又称"油曲连""油曲轮馍")。 这种面食的起源传说,与全国主流的"纪念屈原"叙事截然相反。据乾县当地民间传说与《乾县志》记载,屈原并非被纪念的对象,而是被"庆祝"消失的象征。2011年出版的《乾县志》记载:"端午即农历五月初五,这天外家要给不满十二岁的外甥们送'油曲连'(用面食烙成的各式花型的饼,中间有孔,可以戴在儿童的手臂上)、粽子等。"这里的"油曲连"(外地所没有的民间习俗)是关中乾县、礼泉一带在端午节所做的一种独特面花,用剪刀、梳子等工具在面团上雕刻出花草虫鱼等图案,然后烘烤而成。之所以做成花草鱼虫图案,就是为了驱邪保平安。 ![乾县"屈原馍"——陕西关中独特的端午面花](/images/quyuan/quyuan_mo.webp) ### 1.2 "屈原馍"的含义:庆祝敌仇之死 **"屈原馍"这个名称本身就蕴含着深刻的历史记忆。** 关于这一习俗的起源,乾县民间流传着这样的传说:战国时期,楚国是秦国的最大威胁,而屈原是楚国主张抗秦的核心大臣。当屈原投江而死的消息传到秦地时,正值关中麦子成熟的季节。秦国人为了庆祝这一"敌国心头大患"的消除,做成各种形状的馍,让孩子们吃掉——寓意"屈原没了"。这一习俗经过两千多年的演变,逐渐固化为乾县端午节外婆给外孙送"屈原馍"的节俗。 当地民俗学者指出:"陕西乾县端午节制作'油曲轮馍',传说是民间庆祝屈原投江的大型活动之一。战国时代,秦国疆域最大,驻地咸阳,乾县作为秦国腹地,地理位置险要。当时楚国是秦国最大的威胁,而屈原又是楚国抗秦的力挺者,抗秦策略对秦国构成直接威胁。而后,楚王昏庸,屈原被贬,跳江而死,秦国解除心头大患,朝野上下,举国欢庆。秦国人四处庆祝,民间又把屈原做成各种形状的馍,戴在手上或脖子上,让孩子们吃掉,以示记住这段难忘的历史。" 关中名村陕西乾县马兰寨村当地流传的民谣生动地描绘了这一习俗:"**五月单(端),送圈圈(指'油曲轮馍'),送来拥肚笘肚间。花花绳戴在手腕腕,香包包胸前挂串串。雄花药抹在屁股眼,汤汤面香得打颤颤……**" ### 1.3 从"屈原馍"到"油曲连":名称的蜕变与记忆的重构 值得注意的是,"屈原馍"这一原始名称在流传过程中逐渐被改称为"油曲连"或"油曲轮馍"。这种名称的转变本身就是一种文化记忆的自我修正——随着秦楚敌对状态的终结、秦汉统一后"中华民族"认同的形成,赤裸裸的"庆祝敌国之臣死亡"的表述变得不再适宜。然而,节俗的形式(送馍给外孙、馍的形状、佩戴方式)却顽固地保留了下来,成为历史记忆的"活化石"。 | **维度** | **楚地(湖北湖南)** | **关中(秦地)** | | ------------ | -------------------------------- | ----------------------------------------- | | **节日食物** | 粽子(糯米包裹,投入江中喂鱼虾) | 屈原馍/油曲连(小麦面粉制作,"吃掉屈原") | | **核心象征** | 保护屈原遗体不被侵害 | 庆祝敌国威胁消除 | | **赠送关系** | 无特定赠送仪式 | 外婆/舅舅送给外甥 | | **食用方式** | 煮食,象征投江祭祀 | 佩戴在手腕或脖颈,后食用 | | **名称演变** | 始终称"粽子" | "屈原馍"→"油曲连"(去敌意化) | | **记忆编码** | 纪念与哀思 | 胜利与庆祝 | --- ## 第二章 节俗层面的割裂:插柳戴绳吃花馍,无龙舟无粽子 ### 2.1 关中端午:一个与屈原"无关"的节日 **关中地区的端午节俗呈现出与全国主流截然不同的面貌。** 在乾县、礼泉、西安周至等地,端午节的习俗主要包括:点抹雄黄酒、戴"花花绳"(五彩丝线)、送"裹肚"(红色绣花肚兜)、插柳枝(而非艾草)、以及送"屈原馍"。值得注意的是,**这些习俗中完全找不到龙舟竞渡和吃粽子的踪影**——这两项被全国其他地区视为"纪念屈原"核心符号的活动,在关中大地几乎不存在。 据《西安本地宝》等地方志资料记载,西安地区的端午传统风俗包括:"抹雄黄酒、戴五彩绳、裹肚兜、吃粽子等"。但进一步考察会发现,即使在西安市区,吃粽子的习俗也是近代以来受全国主流文化影响才逐渐传入的,传统的关中端午食物仍然是面食类。而在更偏远的乾县、礼泉等地,粽子则完全缺席。 ![关中端午民俗——外婆送屈原馍给外孙](/images/quyuan/guanzhong_duanwu.webp) ### 2.2 插柳不插艾:秦地端午的植物符号 **在全国多数地区,端午节门前悬挂的是艾草和菖蒲。** 然而,在关中地区,端午节的植物符号却有所不同——部分地区插柳枝代替艾草。 这种差异并非偶然。柳树在秦地文化中具有特殊的象征意义——它既是"留"的谐音(寓意留住福气),也是秦国本土常见的植物。相比之下,艾草更常见于南方潮湿地区。更重要的是,插柳不插艾的选择,也暗含着对屈原叙事的主动疏离:艾草与屈原传说紧密相连,而柳枝则与这一叙事无涉。 | **习俗** | **全国主流(楚地为代表)** | **关中(秦地)** | **核心态度差异** | | -------------- | -------------------------- | ------------------------ | -------------------- | | **龙舟竞渡** | 核心活动,象征打捞屈原遗体 | 完全不存在 | 纪念 vs 无关联 | | **粽子** | 核心食物,象征投江祭祀 | 近代才传入,传统食物为馍 | 哀思 vs 日常 | | **门前植物** | 艾草、菖蒲 | 部分地区插柳枝 | 驱邪护魂 vs 纳福辟邪 | | **五彩绳** | 存在,称为"长命缕" | 高度发达,称"花花绳" | 共同辟邪传统 | | **赠送礼物** | 无特定赠送关系 | 外婆/舅舅送外甥馍、裹肚 | 公共纪念 vs 亲情传递 | | **屈原关联度** | 极高,节日起源归于屈原 | 极低,刻意疏离屈原 | 崇拜 vs 冷淡/敌意 | --- ## 第三章 语言层面的讥讽:关中方言"骚情"=瞎操心、自作多情 ### 3.1 "骚情"的词源:从屈原的"抒情"到关中的"瞎操心" **"骚情"是关中方言中使用频率极高的词汇,通常用来形容"瞎操心""自作多情""轻浮张狂"等负面行为。** 令人惊讶的是,这个词的词源与屈原有着直接的关系。 屈原是中国文学史上"抒情"传统的开创者。"抒情"一词本身就出自屈原的作品。据台湾大学杨儒宾教授的研究:"'抒情'这个词语出自屈原,而且再三出现,现在看来不可能是无意识的。'抒'意味着卷出、抒发之意,亦即'内在的东西使之明白化'的意思,而这种'内在化'指的正是'情'。" 然而,在关中方言中,"骚情"(发音sáo qing)却成为一个贬义词。蓝田学者雷树萱在《不是骚情,是梢轻》一文中指出:"关中方言里有个常用词叫sáo轻,在吾乡口语里,它主要有以下几种意思:1、轻浮、轻佻;2、献媚、讨好;3、不知好歹,张狂;4、多嘴、多事(以致于坏事)。" ### 3.2 从"梢轻"到"骚情":一场文字误读的文化隐喻 雷树萱的研究进一步指出,"骚情"的正确写法应该是"**梢轻**"。"梢"指树梢、枝头,"轻"指轻浮不实在。"梢轻"原指谷物穗子轻、颗粒少(甘肃定西俗语"梢轻没颗子"),后引申形容人的言行举止轻浮、不踏实。 然而,从"梢轻"到"骚情"的误写并非偶然。这种"误写"本身构成了一种深刻的文化隐喻——**秦地人将屈原式的情感表达("骚"——离骚)与"瞎操心""自作多情"等负面评价联系在一起。** 屈原在《离骚》中反复申诉自己的忠心不被理解、理想无法实现,在秦地人看来,这恰恰是"不识时务""多管闲事"的典型表现。 | **层面** | **屈原的"抒情"** | **关中方言"骚情/梢轻"** | | ------------ | ------------------------ | -------------------------- | | **词源** | "抒"=抒发,"情"=内在情感 | "梢"=枝头,"轻"=轻浮 | | **情感色彩** | 正面、崇高、真挚 | 负面、轻浮、多事 | | **行为指向** | 为国忧民、追求理想 | 瞎操心、自作多情、不识时务 | | **文化态度** | 赞美、同情、崇敬 | 否定、嘲弄、冷淡 | | **精神内核** | 儒家理想主义 | 法家务实精神 | --- ## 第四章 诗歌层面的否定:从白居易到欧阳修——"独醒从古笑灵均" ### 4.1 白居易"独醒从古笑灵均":醉酒对独醒的嘲弄 **唐代大诗人白居易对屈原的态度,集中体现了中原知识分子对这位"独醒者"的复杂评价。** 白居易在《咏家酝十韵》中写下了流传千古的名句:"**独醒从古笑灵均,长醉如今敩伯伦。**"这两句诗的意思非常明确:自古以来,人们都嘲笑屈原独自清醒;如今我要效仿刘伶,长久地沉醉于酒中。 这种态度在白居易的另一首诗《咏怀》中表现得更加直接:"**自从委顺任浮沉,渐觉年多功用深。面上减除忧喜色,胸中消尽是非心。……长笑灵均不知命,江蓠丛畔苦悲吟。**"在这首诗中,白居易不仅"笑"屈原,更是"长笑"——持久地、深深地嘲笑屈原"不知命"。 ### 4.2 欧阳修"可笑灵均楚泽畔":北宋文人的历史俯视 **北宋文坛领袖欧阳修在《啼鸟》一诗中,对屈原发出了更加直白的嘲弄:**"**可笑灵均楚泽畔,离骚憔悴愁独醒。**" "可笑"二字的分量极重——它不仅仅是个人情感的表达,更代表了北宋士大夫阶层对屈原的历史俯视。在欧阳修看来,屈原的行为模式是不可理喻的:明明可以随遇而安、与花鸟为友,却偏偏要"独醒"、要"忧国忧民"、要"憔悴愁苦"——这不是"可笑"又是什么呢? | **诗人** | **朝代** | **诗句** | **核心态度** | | ---------- | -------- | -------------------------------- | -------------------- | | **白居易** | 唐 | "独醒从古笑灵均,长醉如今敩伯伦" | 嘲笑独醒,选择沉醉 | | **白居易** | 唐 | "长笑灵均不知命,江蓠丛畔苦悲吟" | 嘲笑不知命,否定悲吟 | | **欧阳修** | 宋 | "可笑灵均楚泽畔,离骚憔悴愁独醒" | 直接嘲弄,认为可笑 | | **赵冬曦** | 唐 | "勿学灵均远问天" | 劝人不要学屈原 | | **元稹** | 唐 | "哀哉徇名士,没命求所难" | 同情但认为不值得 | --- ## 第五章 思想层面的对立:法家务实 vs 儒家理想主义 ### 5.1 屈原:一个"南方的儒者" 郭沫若先生在《屈原思想》一文中提出了影响深远的观点:"屈原思想明显有儒家风貌,注重民生,倡导德政,注重修己以安人,所以,**屈原是一位南方的儒者**。" 中国屈原学会会长方铭教授进一步指出:"与其说屈原是法家或者改革家,毋宁说他是一个坚守传统的儒家思想家。他的思想价值,不在于他在战国时期体现了怎样的改革意识,而在于他知道人民的幸福依靠回归'选贤举能'的美政。" ![法家与屈原的思想对决——务实与理想的对立](/images/quyuan/fajia_vs_quyuan.webp) ### 5.2 法家精神:秦国的立国之本 **秦国的崛起,离不开法家思想的指导。** 从商鞅变法开始,秦国就走上了一条与楚国截然不同的道路。法家的核心主张是"不别亲疏,不殊贵贱,一断于法"(司马迁语),强调以严刑峻法来治理国家,以功利实效来评价政策。法家不相信感情,只相信利益;不相信文化,只相信刀剑。 商鞅在秦国的变法包括:废井田、开阡陌,重农抑商,实行郡县制,迁都咸阳,统一度量衡,奖励耕织和战斗,实行连坐之法。这些措施的共同特点是将贵族和百姓一视同仁,以法律为唯一准绳。 郭沫若先生曾对比吴起与商鞅的命运:"假使让吴起在楚国多做得几年,使他的政治得以固定下来,就像商鞅日后在秦的一样,行了法22年,虽然死了,法也没有变动,那么战国时代的中国,恐怕就不必等到秦国来统一了。" | **对比维度** | **法家(秦国)** | **儒家/屈原(楚国)** | | ---------------- | -------------------- | --------------------- | | **核心主张** | 严刑峻法、富国强兵 | 仁政德治、选贤举能 | | **人性观** | 人性本恶,需法律约束 | 人性本善,需道德教化 | | **政治手段** | 赏罚分明、以势压人 | 修身齐家、以德服人 | | **改革策略** | 彻底废除贵族特权 | 有限度的改良 | | **对屈原的评价** | "政治幼稚""不识时务" | "忠贞爱国""理想崇高" | | **实践结果** | 秦统一六国 | 楚为秦所灭 | --- ## 第六章 当代解构:"阻碍统一""战胜国何必纪念" ### 6.1 "屈原阻碍统一"论的历史渊源 学者郭维森在《屈原》一书中提出了有力的反驳:"我们不能认为只有秦来统一才是进步,否则就是倒退。事实上,战国时代的具体情况是:第一,当时各国都已进入了封建社会,七国中秦国并非是先进生产关系的唯一代表。第二,当时流行着'从合则楚王,横成则秦帝'的说法,这说明秦、楚、齐都有统一中国的可能。第三,秦在军事上取得相对优势后,采取了分化瓦解、各个击破的策略,因此楚、齐等国就有联合抵抗的必要。" 更重要的是,秦国的统一战争充满了掠夺性和破坏性。秦赵长平之战中,秦将白起坑杀赵国降卒四十万人;白起攻破楚国郢都后,焚烧了楚王历代陵墓。这种暴行使得"统一"的正义性大打折扣。 ### 6.2 "战胜国何必纪念战败国之臣" **网络时代兴起了一种更为极端的解构声音:"秦国是战胜国,楚国是战败国,战胜国何必纪念战败国的臣子?"** 这种观点虽然缺乏学术严谨性,却在社交媒体上有一定市场。 这种观点的荒谬之处在于,它将政治立场完全凌驾于文化价值之上。屈原之所以被纪念,不是因为他是"楚国的政治家",而是因为他代表了人类对理想的坚守、对正义的追求、对自由的向往。1953年,世界和平理事会将屈原列为世界四大文化名人之一(另外三位是哥白尼、拉伯雷、何塞·马蒂),正是对其普世价值的国际认可。 --- ## 第七章 历史根源:屈原为何成为秦人的"敌国符号" ### 7.1 秦楚争霸:血火中结下的世仇 **要理解秦地对屈原的敌意,必须回到战国时代秦楚两大强国争霸的历史现场。** 楚国是战国时期疆域最大的国家,"地方五千里,带甲百万",雄踞长江以南。秦国则通过商鞅变法迅速崛起,成为军事最强国。两国之间的冲突贯穿了整个战国中后期。 屈原担任楚国左徒期间,正是秦楚斗争最激烈的时期。他主张"联齐抗秦",积极组织合纵联盟对抗秦国扩张。在屈原的努力下,齐国与楚国一度结盟,形成对秦国的战略威慑。 ### 7.2 张仪欺楚:秦国谋略对屈原理想的碾压 **张仪欺楚事件是屈原政治生涯的转折点。** 秦国派张仪出使楚国,以"商於之地六百里"为诱饵,成功离间了齐楚联盟。楚怀王贪图便宜与齐国绝交后,张仪却只承认"六里"而非"六百里"。楚怀王大怒攻秦,结果在丹阳之战中损失惨重——**楚军8万余人被歼,70多名将领被俘,屈氏家族的军事领袖屈丐自杀。** ### 7.3 白起破郢:压垮屈原的最后一击 **公元前278年,秦国大将白起率军攻破楚国都城郢都,这是屈原投江的直接导火索。** 白起不仅占领了郢都,还焚烧了楚国历代先王的陵墓夷陵。对于一个以宗法制度为核心的国家而言,祖坟被焚是不可饶恕的奇耻大辱。 屈原此时已被流放到沅湘流域多年。当郢都沦陷的消息传来时,屈原的复国希望彻底破灭。在农历五月初五这一天,他怀抱大石投入汨罗江,以死明志。 ![屈原与秦国关系关键事件时间线](/images/quyuan/timeline.webp) --- ## 结语:多元记忆与历史和解 **秦地"恨"屈原,是一种真实存在的历史记忆,不应被简单否定或遮蔽。** 从乾县的"屈原馍"到白居易的"笑灵均",从关中方言的"骚情"到法家对儒家理想主义的批判,这套"反屈原"记忆体系有着深厚的历史根源和完整的逻辑自洽。它提醒我们:历史从来不是单声部的合唱,而是多声部的交响。 然而,承认这种记忆的合理性,并不意味着我们要放弃对屈原的崇敬。恰恰相反,正是在这种多元记忆的对话中,屈原的形象才更加丰满、更加真实。他不是被供奉在神坛上的完美偶像,而是一个有血有肉、有爱有恨的历史人物——楚人爱他,秦人恨他,中原人笑他,而两千年后的我们,在这一切情感的交织中,最终理解了一个真理:**理想主义者的悲剧,不在于他的理想是否实现,而在于他为理想付出的一切,本身就是人类文明最珍贵的财富。** 端午节,当我们吃粽子、赛龙舟时,不妨也想一想乾县的"屈原馍"——那个以面粉塑形、佩戴在孩童手腕上的圆圈。它提醒着我们:**历史的另一面,同样值得被聆听。** ![各地对屈原纪念态度对比分析](/images/quyuan/comparison.webp) --- ## 附录:Mermaid 图表 ### 图1:秦地对屈原态度的传承体系 ```mermaid graph TD A[秦地对屈原的否定态度] --> B[食物层面] A --> C[节俗层面] A --> D[语言层面] A --> E[诗歌层面] A --> F[思想层面] B --> B1["乾县屈原馍
'屈原没了'"] C --> C1[无龙舟无粽子
插柳戴绳吃花馍] D --> D1["方言'骚情'
讥讽自作多情"] E --> E1["白居易'独醒笑灵均'
欧阳修'可笑灵均'"] F --> F1[法家务实
否定儒家理想主义] G[历史根源] --> G1[秦楚争霸
血火世仇] G --> G2[屈原联齐抗秦
秦国最大威胁] G --> G3[白起破郢
屈原投江] G1 --> A G2 --> A G3 --> A ``` ### 图2:屈原"联齐抗秦"战略与秦国反制 ```mermaid sequenceDiagram participant 屈原 as 屈原/楚国 participant 齐国 as 齐国 participant 秦国 as 秦国/张仪 participant 楚怀王 as 楚怀王 屈原->>齐国: 出使结盟(合纵) 齐国-->>屈原: 同意联盟 Note over 屈原,齐国: 齐楚联盟形成
对秦构成战略威胁 秦国->>楚怀王: 张仪出使
"以商於六百里换齐楚绝交" 楚怀王-->>秦国: 同意!与齐绝交 屈原->>楚怀王: 力谏不可!
秦乃虎狼,不可信! 楚怀王->>屈原: 疏远、流放 秦国-->>楚怀王: "六百里?我说的是六里" 楚怀王->>秦国: 怒而攻秦 Note over 楚怀王,秦国: 丹阳之战
楚军8万被歼,70+将领被俘 楚怀王->>屈原: 召回,出使齐修复关系 屈原->>齐国: 再次出使 Note over 秦国: 秦国贿赂
郑袖、靳尚 楚怀王->>屈原: 再次流放 楚怀王->>秦国: 赴会,被囚三年 Note over 楚怀王: 客死咸阳
公元前296年 秦国->>屈原: 白起破郢
公元前278年 屈原->>屈原: 五月五日投江 ``` ### 图3:法家vs儒家思想对比框架 ```mermaid graph LR subgraph 秦国法家路线 A1[商鞅变法] --> A2[废井田开阡陌] A2 --> A3[郡县制替代分封] A3 --> A4[奖励耕战] A4 --> A5[严刑峻法] A5 --> A6[秦统一六国] end subgraph 楚国儒家路线 B1[吴起变法] --> B2[失败被杀] B2 --> B3[屈原变法] B3 --> B4[贵族反扑] B4 --> B5[屈原流放投江] B5 --> B6[楚为秦灭] end A6 -.->|两种命运| B6 ``` --- ## 参考文献 1. 司马迁. 史记·屈原贾生列传[M]. 北京: 中华书局, 1959. 2. 白居易. 白氏长庆集[M]. 上海: 上海古籍出版社, 1988. 3. 欧阳修. 欧阳文忠公集[M]. 北京: 中华书局, 1990. 4. 郭沫若. 屈原思想[J]. 郭沫若全集, 1942. 5. 方铭. 正道直行的屈原[N]. 光明日报, 2021-06-11. 6. 雷树萱. 不是骚情,是梢轻[J]. 樹諼草微信公众号, 2025-08-18. 7. 乾县志编纂委员会. 乾县志[M]. 西安: 陕西人民出版社, 2011. 8. 杨儒宾. 屈原为什么抒情[J]. 台大中文学报, 2015. 9. 许田波. 战争与国家形成:春秋战国与近代早期欧洲之比较[M]. 上海: 上海人民出版社, 2009. 10. 梁涛. 屈原变法与楚国政治[J]. 中国社会科学院研究生院学报, 2003. --- ## 回顾经典-程序员的三大美德 URL: https://xingwangzhe.fun/posts/programmers-three-virtues/ License: CC-BY-NC-SA-4.0 > **本文存在AI修饰** ## 引言 现在AI的发展非常迅速,我也在追热点,可以说各种概念是**几天一遍**,各种方法是**几周一遍**,各种范式**一个月一遍**,目不暇给!我决定一直追求热点也不是太好的事,我不能**失其本心**,我想考据一下曾经古法时代的一些**非遗**文化,这些文化在现代编程中仍然具有重要的价值,比如说我之前写的 [Flash 考古](https://xingwangzhe.fun/posts/linux-flash-roco)、[QQ 记忆考古](https://xingwangzhe.fun/posts/find-qq-memory)、[GNU 42 周年回顾](https://xingwangzhe.fun/posts/c08b9de1)、[GPL 许可证考据](https://xingwangzhe.fun/posts/gpl-2-3-thing)、[GPG 公钥文化](https://xingwangzhe.fun/posts/f74e64e5)、[再谈自由软件](https://xingwangzhe.fun/posts/945d7e3a)……今天我们来回顾另一个经典——**程序员的三大美德**。 ## 三大美德的出处 说到**程序员的三大美德**,就不得不提 Larry Wall——Perl 语言之父。他在 1991 年出版的《Programming Perl》(第一版)前言中,提出了这三个让程序员又爱又恨的品质:**懒惰**(Laziness)、**急躁**(Impatience)、**傲慢**(Hubris)。 说实话,第一次看到这三个词的时候,我以为这是个玩笑。但它不是。Larry Wall 用一种反讽的方式,精准地描述了优秀程序员的核心驱动力: | 美德 | 原文定义 | 通俗理解 | | -------- | ---------------------------------- | ---------------------------------------- | | **懒惰** | 为了减少总体能量消耗而付出巨大努力 | 能自动化的绝不手动,能写脚本的绝不点鼠标 | | **急躁** | 当计算机偷懒时你感到的愤怒 | 慢一秒都不行,出了问题立刻排查 | | **傲慢** | 过度的骄傲,宙斯会因此劈你的那种 | 写出来的代码要让别人挑不出毛病 | > The quality that makes you go to great effort to reduce overall energy expenditure. It makes you write labor-saving programs that other people will find useful, and document what you wrote so you don't have to answer so many questions about it. > —— Larry Wall,《Programming Perl》 不得不承认,这三大美德放到现在的 AI 时代依然好使。而且回头翻翻我自己的博客,你会发现我早就不知不觉地在践行它们了。 --- ## 第一美德:懒惰 懒惰不是不干活,而是**为了以后少干活,现在多写点代码**。 回过头来看,我在性能优化和自动化上折腾得最多。比如 [Astro 5.17 构建性能优化实践](https://xingwangzhe.fun/posts/astro-517-performance-optimization),构建从 18s 压到 13s——说实话,省这几秒不是为了别的,就是受不了每次 `bun run build` 之后还要等。这就是典型的**懒惰驱动优化**。 还有 [github action/workflow 自动发布 npm 包](https://xingwangzhe.fun/posts/5561),手动发布?不可能的。配好 workflow,push 一个 tag 就自动走完发布流程。以及 [hexo 优化网站性能记录](https://xingwangzhe.fun/posts/9f6ebe30),断断续续折腾了几个月,加缓存、压图片、去无用 CSS……每一次优化都是因为**忍不了**。 Larry Wall 说得对:懒惰的程序员会写文档,因为他不想反复回答同样的问题。这也是为什么我的博客里塞满了各种**踩坑记录**和**解决指南**——不是乐于助人,纯粹是**不想再说第二遍**。不过今天来看,这些都是 AI 几分钟就能解决的问题。但换个角度想,这些当年的踩坑记录,既锻炼了自己,现在也训练了 AI——至少它们是真人踩坑换来的,不是 AI 自产自销的幻觉。这大概就是**非遗**传承的另一层含义:前人栽树,后人乘凉,AI 也在树下。 --- ## 第二美德:急躁 急躁是当计算机偷懒时你感到的愤怒。它驱使你立刻动手修复,而不是**等一等也许就好了**。 我博客里最能体现这一点的,就是 Bing 收录相关的几篇了——[Bing 收录没了?亲测有效的快速恢复指南](https://xingwangzhe.fun/posts/bing-re-index)、[SEO 优化:期待拯救我的 bing 搜索](https://xingwangzhe.fun/posts/8811)。收录一掉,连夜排查 sitemap、手动提交索引、折腾 IndexNow API……说实话,搜索引擎收录这种话题其实挺无聊的,但你搜不到自己的博客?那可不行! 再往前看,[别让 AI 替你捣乱——面向零软件工程经验新人的指南](https://xingwangzhe.fun/posts/zero-se-newcomer-guide) 也是**急躁**驱动的产物。看到仓库被噪音 PR 轰炸,GitHub 频频崩溃,实在是忍不下去了,连夜写了一篇指南。这种**看不下去所以自己动手**的冲动,就是急躁美德的最好体现。 但今天的 AI 时代,出现了一种变味的急躁——不断鞭策 AI 去做、再去做,自己只负责按回车。有种自己当上了只会催进度的领导,而 AI 才是真正写代码的人的感觉。说实话,这不是 Larry Wall 说的急躁美德。Larry Wall 的急躁是**自己上手修**,AI 时代的这种急躁是**催别人修**。一字之差,天壤之别。 --- ## 第三美德:傲慢 傲慢是那种**我写的东西别人挑不出毛病**的骄傲。Larry Wall 说这是会被宙斯雷劈的品质,但在编程世界里,它恰恰是质量保证的驱动力。 作为开源爱好者,这条我深有体会。从最早的 [hexo-theme-wang:一个简约的暗色主题](https://xingwangzhe.fun/posts/59667),到后来的 [Stalux Astro 博客主题自荐](https://xingwangzhe.fun/posts/stalux-astro),每一次换主题本质上都是**我觉得我可以写一个更好的**。尤其是 Stalux,从 Hexo 迁移到 Astro 之后,[博客主题的软著下来了](https://xingwangzhe.fun/posts/32b402b0),拿到了国家版权认证——这种**我的作品值得被保护**的感觉,怎么说呢,确实带着点傲慢。 最近一个例子是 [我做了一个现代 Web 版本的标签云](https://xingwangzhe.fun/posts/modern-tags-cloud-3d),支持 3D 旋转、图片视频嵌入。市面上标签云组件那么多,但我偏要自己造一个。不为别的,就是因为**别人的实现不够好**。 但傲慢不止于**自己造轮子**,也体现在给别人的项目挑毛病、提 PR。比如我给 VitePlus 提的 [一个小贡献](https://xingwangzhe.fun/posts/contribute-vite-plus)——给 `vp create` 加了模板预设,没想到竟然上了 **Highlights**。说实话,想到以后会有人复用我写的这段模板,确实有点小激动。这不就是傲慢吗——觉得自己的代码值得被成百上千的人用。 同样还有 [Waline 被莫名索引问题解决](https://xingwangzhe.fun/posts/f654ae55)。Waline 的首页被搜索引擎索引了,按理说应该有 `robots.txt` 挡一下,但因为没有正确声明静态资源路由,导致 `robots.txt` 形同虚设。我顺着 Vercel 的 rewrite 规则和正则一路排查,最后提了两个 PR 把这事修了。这事说白了就是:**你这个评论系统的路由设计有问题,我来帮你改好。**——这不叫傲慢叫什么。 --- ## 结语 回过头来看,这三大美德之所以能穿越三十年依然是经典,是因为它们描述的不是具体的技术,而是**优秀程序员的内在驱动力**。 AI 可以写自动化脚本、可以排查 bug、也可以反复迭代一个开源项目——agent 自动化水平已经到了这个程度,技术上没什么是它不能做的。但三大美德描述的从来不是**行为**,而是行为背后的**情感冲动**:忍不了、看不惯、懒得做。AI agent 可以模仿这些行为,但它不会真的忍不了,不会真的看不惯,更不会真的懒得做——它只是忠实地执行指令。而三大美德之所以是美德,恰恰在于那种亲手做事的烦躁、焦虑、骄傲,是由内而外涌出来的,不是被 prompt 出来的。 古法时代的**非遗**文化之所以值得考据,不是因为它技术先进,而是因为它描述的是一种态度。一种我在追热点追到眼花缭乱时,需要回头看一眼的态度。三大美德如此,GNU、GPL、GPG、自由软件……皆是如此。 > Laziness, Impatience, Hubris. > —— Larry Wall,1991 > 十年前没人帮你写代码,十年后 AI 替你写。但十年前踩过的坑,十年后 AI 替你踩不了。 > —— 一个还在折腾博客的学生 --- ## 我做了一个现代Web版本的标签云,支持图片视频Web组件 URL: https://xingwangzhe.fun/posts/modern-tags-cloud-3d/ License: CC-BY-NC-SA-4.0 > 本文由DeepSeek润色 标签云页面Demo: [https://tagscloud.needhelp.icu/](https://tagscloud.needhelp.icu/) ## 前言 最近想美化一下博客的标签云页面。我希望标签能在一个 3D 球面上旋转。搜了一圈发现现有的轮子 [cong-min/TagCloud](https://github.com/cong-min/TagCloud)——已经是 2017 年的作品了:ES5 编写、仅支持纯文本、纯 DOM 渲染。而且我还有一些"奇思妙想" ——比如让标签云里混入图片、视频、甚至 Web Components。 于是我决定从零重构一个现代版本:**[@xingwangzhe/tags-cloud](https://www.npmjs.com/package/@xingwangzhe/tags-cloud)**。 ## 先看源码 原项目 [TagCloud](https://github.com/cong-min/TagCloud) 的核心算法其实非常优雅,值得保留。它由三个纯数学模块组成: ### 数学计算 > 好久都没做过纯数学题了,在Deepseek写代码的时候,顺便问一下这都是什么物理意义,计算能算,但逻辑需要思考很长时间... **1. 斐波那契球面分布** 把 N 个标签均匀地散布在球面上,不是一件简单的事。如果直接按经纬度等距切分,极点附近的点会被挤压在一起。旧库用了一个巧妙的方案: $$ \begin{aligned} \phi(i) &= \arccos\left(-1 + \frac{2i+1}{N}\right) \\[4pt] \theta(i) &= \sqrt{N\pi} \times \phi(i) \\[4pt] P(i) &= \bigl(R\sin\phi\cos\theta,\ R\sin\phi\sin\theta,\ R\cos\phi\bigr) \end{aligned} $$ $(2i+1)$ 的偏移确保没有任何点恰好落在球面极点。$\sqrt{N\pi}$ 的黄金螺旋角让相邻点之间的经度差保持无理数比例,避免视觉上的对齐条纹。时间复杂度 $O(N)$,100 个标签瞬间完成。 **2. 旋转矩阵** 交互体验用的是 **Shoemake Arcball**——一种基于四元数的 3D 旋转方案。用户拖拽时,屏幕坐标被投影到虚拟球面上,起点和终点之间构造一个四元数差量,然后叠加到当前旋转状态。相比欧拉角,四元数没有万向锁问题,旋转更流畅。 **3. 透视投影** $$ \begin{aligned} per &= \frac{4R}{4R + z} \\[4pt] \alpha &= \operatorname{clamp}(per^2 - 0.25,\ 0,\ 1) \\[4pt] x_{screen} &= c_x + x_{rot} \times per \\[4pt] y_{screen} &= c_y + y_{rot} \times per \end{aligned} $$ Z 轴越深(远离屏幕)→ $per$ 越小 → 标签缩小 + 变透明。$per^2 - 0.25$ 的公式让远处的标签更快地淡出视野,避免球面背面的标签干扰视觉。近处的标签($per \approx 1$)$\alpha = 0.75$,清晰可见。 ### 渲染,但是我不想用 DOM 旧库的渲染方式是把每个标签做成一个 `` 元素,每帧更新它的 `transform` 和 `opacity`。100 个标签就是 100 个 DOM 节点在每一帧被重新布局——性能可想而知。 我的想法是:**数学留在 CPU 里,渲染尽量走 Canvas**(文本和图片)。需要交互的富媒体(SVG、视频、Web Components)保留 DOM overlay。Canvas 的 `fillText` 和 `drawImage` 是像素级操作,不触发回流,60fps 毫无压力。 ## 用 TS 改写 从 ES5 到 TypeScript 不只是加类型标注。整个架构被拆成了清晰的模块边界: ```txt title="项目结构" src/ ├── core/ │ ├── distribution.ts // 斐波那契球面分布 │ ├── rotation.ts // 旋转变换 │ └── projection.ts // 透视投影 ├── TagCloud.ts // 主引擎 └── index.ts // 导出入口 ``` ```ts title="基础用法" import { TagCloud } from "@xingwangzhe/tags-cloud"; const cloud = new TagCloud(document.getElementById("cloud"), { tags: ["TypeScript", "Canvas", "3D", "Astro", "Bun"], radius: 300, spinY: 0.15, // Y 轴自旋速度(°/帧) fontSize: 16, color: "#ffffff", onTagClick(item) { if (typeof item === "string") { window.location.href = `/tags/${item}/`; } }, }); // 运行时 API cloud.setTags(["新的", "标签", "列表"]); cloud.pause(); cloud.resume(); cloud.destroy(); ``` 类型系统让配置项一目了然。`TagCloudOptions` 的每一个字段都有 JSDoc,IDE 里悬停就能看到中英文说明。 ## 多模态 这是新库最大的亮点——**不再局限于纯文本**。`tags` 参数接受一个联合类型: ```ts title="TagItem 联合类型" type TagItem = | string // 纯文本 → Canvas 渲染 | { type: "image"; ... } // 图片 → Canvas 渲染 | { type: "svg"; ... } // SVG → DOM overlay 渲染 | { type: "html"; ... } // HTML → DOM overlay 渲染 | { type: "video"; ... } // 视频 → DOM overlay 渲染 | { type: "element"; ... } // 任意元素 → DOM overlay 渲染 ``` 渲染引擎自动分流:文本和图片走 Canvas 获得最佳性能;SVG、HTML、视频和 Web Components 走 DOM overlay 保持交互性和可访问性。 ### 图片 ```ts title="图片标签" new TagCloud(container, { tags: [ { type: "image", src: "/avatar.webp", width: 40, height: 40, onClick: () => open("/profile"), }, "JavaScript", "TypeScript", ], radius: 300, spinY: 0.15, }); ``` 图片通过 `CanvasRenderingContext2D.drawImage()` 绘制,支持自定义宽高和点击回调。头像、Logo、图标都可以混在文字标签中间,在 3D 球面上一起旋转。 ### 视频 ```ts title="视频标签" new TagCloud(container, { tags: [{ type: "video", src: "/demo.mp4", width: 120, height: 68 }, "前端", "全栈"], radius: 350, spinY: 0.1, }); ``` 视频标签走 DOM overlay 渲染,`autoplay muted loop playsinline` 自动静音循环播放。点击视频标签会触发全屏——想象一下在标签云里漂浮着一段产品 Demo 的缩略视频。 ### Canvas 渲染 整个 Canvas 渲染器是内置的,但完全可替换。`onRender` 回调暴露了每帧的投影数据: ```ts title="自定义渲染回调" new TagCloud(container, { tags: ["A", "B", "C"], onRender(tags) { // tags: TagData[] — 每帧的投影坐标 // { item, x, y, z, scale, alpha }[] tags.forEach((t) => { // 你可以用 Three.js、PixiJS 或任何方式绘制 }); }, }); ``` 如果不传 `onRender`,引擎会用内置的 Canvas 渲染器:自动创建 ``、处理高 DPI 缩放、Z 排序后逐层绘制文本和图片。DOM overlay 也自动管理——创建、更新 transform、清理已移除的标签。 内置渲染器的细节: - Canvas 绘制文本和图片(高性能像素操作) - DOM overlay 渲染 SVG/HTML/Video/Element(保持交互性) - 每帧按 Z 深度排序(远处的先画),实现正确的遮挡关系 - 点击检测用 raycast——遍历上一帧的 Canvas 标签坐标,找最近的命中 ## 核心 API 一览 | 选项 | 类型 | 默认值 | 说明 | | ----------------- | ----------- | ----------- | ------------------------- | | `tags` | `TagItem[]` | — | 标签列表 | | `radius` | `number` | `300` | 球面半径 (px) | | `spinY` | `number` | `0` | Y 轴自旋速度,+右转 -左转 | | `spinX` | `number` | `0` | X 轴自旋速度,+下转 -上转 | | `reverse` | `boolean` | `false` | 反转拖拽方向 | | `inertiaDecay` | `number` | `0.96` | 惯性衰减系数 | | `dragSensitivity` | `number` | `3` | 拖拽灵敏度 | | `fontFamily` | `string` | `system-ui` | 字体 | | `fontSize` | `number` | `14` | 字号 (px) | | `color` | `string` | `#fff` | 文字颜色 | | `onTagClick` | `function` | — | 点击回调 | | `onRender` | `function` | 内置 | 自定义渲染器 | 实例方法:`setTags()`、`pause()`、`resume()`、`destroy()`。 ## 性能 | 指标 | 数值 | | --------------- | ---------------------------- | | Bundle 大小 | ~12KB (ESM) / ~3KB (gzipped) | | 零运行时依赖 | 是 | | 100 标签 帧耗时 | < 5ms (旋转+投影+排序+渲染) | | 内存占用 | ~6KB (100 个标签的浮点坐标) | 数学计算全部是标量运算,没有矩阵乘法库依赖。每帧的浮点运算量:$N$ 个标签 $\times$ ($8$ 次乘法 $+$ $4$ 次加法) 用于旋转矩阵变换 $+$ $1$ 次除法用于透视投影 $+$ $O(N \log N)$ 的 Z 排序。不碰 WebGL,纯 CPU 计算在 60fps 下完全够用。 ## Demo 在线 我的博客标签云实例 [https://xingwangzhe.fun/tags/](https://xingwangzhe.fun/tags/) 标签云页面Demo: [https://tagscloud.needhelp.icu/](https://tagscloud.needhelp.icu/) npm 安装: ```bash title="安装" bun add @xingwangzhe/tags-cloud # 或 npm install @xingwangzhe/tags-cloud ``` GitHub: [https://github.com/xingwangzhe/tags-cloud](https://github.com/xingwangzhe/tags-cloud) 欢迎 Star 和 PR! --- ## 别让AI替你捣乱-致零软件工程经验新人的指南 URL: https://xingwangzhe.fun/posts/zero-se-newcomer-guide/ License: CC-BY-NC-SA-4.0 > 本文核心观点、结构与内容由本人撰写,AI 仅参与排版修饰与润色。目的就是强调人的主观能动性与 AI 指挥协作。 --- ## 引言 GitHub 最近这几个月频繁崩溃,由于我订阅了 [GitHub status](https://www.githubstatus.com/) 的邮件通知,导致某一天半夜连续受到几十封**邮件轰炸**! > **根本原因**:胡乱的 PR,没完没了地跑 CI/CD,让 AI 替代人做各种事情,却不加以任何思考。 最近维护仓库也看到了许多**噪音 PR**。我觉得真的非常有必要讲讲——AI 时代下,**零软件工程经验的人如何去贡献**。 --- ## 软件工程的概念 > 将系统化的、规范的、可量化的方法应用于软件的开发、运行和维护,以及对这些方法的研究 —— IEEE 当然不止软件——所有工科都学过至少一门工程管理课。作为外部 contributor,你至少需要一点工程知识,才能做好贡献。不过这只是**充分不必要条件**: | 条件 | 说明 | | ------------ | ---------------------------------- | | 充分但不必要 | 上过工程管理课不等于一定能做好贡献 | | 必要但不充分 | 没上过课不等于做不好贡献 | 你不必上那些"务虚"的课——只要你做过项目、参与过软件协作、写过 Issue、提过 PR,自然会懂。 > 有人说这不就成了悖论了吗:没经验很难做好 PR,但不做又没有经验。**你说的对。** 所以本文讲的就是——**面向零软件工程经验新人的指南**。 --- ## 万事开头难,先了解项目 AI 提效,首先在于快速了解项目。我可以假定你不会看每一行代码,但至少要看: - `README.md` - `CONTRIBUTING.md` - `AGENTS.md` / `CLAUDE.md`(仓库给 AI 准备的文档) ### 架构分析 让 AI 帮你画出项目架构图,比从零啃代码高效得多: | 模式 | 适用场景 | 输出格式 | 稳定性 | | ------------ | -------------------------------- | ------------ | -------------------- | | TUI 模式 | 终端命令行工具(如 Claude Code) | ASCII 图表 | 终端宽度变化可能错位 | | ChatBox 模式 | Web 端 AI 对话工具(如 ChatGPT) | Mermaid 图表 | 渲染稳定,可截图保存 | **TUI 模式** — 用 ASCII 作画: ```markdown 请你阅读当前仓库的 README, CONTRIBUTING, AGENTS.md 等文档, 然后分析架构,最后画出 ASCII 图表来展现架构。 ``` **ChatBox 模式** — 用 Mermaid 渲染: ```markdown 请你阅读当前仓库的 README, CONTRIBUTING, AGENTS.md 等文档, 然后分析架构,最后画出 Mermaid 图表来展现架构。 ``` > **新人建议**:优先使用 ChatBox 模式的 Mermaid 图表,渲染稳定、可截图反复参考。 --- ### 隐性知识之一:错综复杂的包依赖 > 我想应该没有哪个新人蠢到不自知,去用 AI 贡献基础库却一点人工 review 都不做。 大部分贡献发生在**应用层软件**上。尽管仓库文档看似实现了知识闭包,但这些项目无一例外地依赖: - 大量**第三方库** - 一两个**主体框架** 这就是**隐性知识之一**——维护者默认你已经了解这些包了,但对新人来说,这是完全空白的领域。AI 也不会主动提这些事,因为它默认你懂。
补充提示词:快速补全依赖知识 ```markdown 分析完架构之后,请你再分析: 1. 当前项目的包管理器(npm / pnpm / yarn / cargo 等) 2. 核心第三方包及其作用 3. 主体框架是什么 4. 这些第三方的文档链接 ```
> **核心目的**:避免**重复造轮子**与低效代码。 > 明明查文档在配置文件里改一行开关就能实现的功能,非要用 AI vibe 出一个复杂度爆炸、难以 review 的实现——得不偿失。 --- ### 隐性知识之二:编码规范与测试要求 每个项目都有自己的代码风格和测试要求。在你动手写代码之前,务必让 AI 帮你理清: ```markdown 请分析当前项目的: - 代码风格(缩进、命名规范、注释习惯) - 是否有 linter 配置 - CI 中跑哪些测试 - 提交 PR 前需要通过什么检查 ``` > 这一步花不了几分钟,但能避免你辛辛苦苦写出的 PR 因为格式问题或 CI 挂掉而被直接关闭。 --- ## 准备贡献,先看 Issue > **请不要直接 PR!作为新人,应该先去 Issue 看看。** 你也许有奇妙的想法、天马行空的创造,但请先看看 Issue,避免撞车。 安装 [`gh CLI`](https://cli.github.com/),让 AI agent 帮你快速筛选: ```bash # 带有 good-first-issue 标签——最适合新人入手 gh issue list --label "good first issue" --limit 20 # 按关键词搜索 gh issue list --search "bug" --limit 20 ``` --- ### 场景 A:解决现有 Issue 如果你的想法和某个 Issue 吻合: 1. 在 Issue 下留言,表示准备接手 2. 去理解内容,补全必要的隐性知识 3. 再动手写代码 > **重要**:很多仓库要求你先在 Issue 下留言申请被 assign,再开始写代码。直接闷头写完才发现没人 assign 你——PR 可能白做。 --- ### 场景 B:提出新 Issue 一个好的 Issue = **清晰表达** + **解决思路** + **结构思考**。 很多仓库的 Issue 都有模板。请你: - [ ] 按照模板逐项填写 - [ ] **不要删除模板**然后直接粘贴 AI 内容 - [ ] 用 `gh` 命令让 AI 按模板格式填写 - [ ] 完成前置任务(看文档、跑测试、签 CLA 等) - [ ] 最后再创建 Issue ```markdown 请你查看当前仓库的 Issue 模板,根据我要反馈的内容按照模板格式填写, 不要跳过任何必填项。填写完成后用 gh issue create 命令创建。 ``` --- ## 准备贡献,最关键的是 PR **PR** = Pull Request(拉取请求),把你自己的代码变更提交给上游仓库、请求合并。 这是整个贡献流程的**临门一脚**,也是最容易翻车的地方。借助 AI 创建 PR 之前,以下关键点**你必须亲自把关**: --- ### 1. 关联 Issue PR 描述中加上 `Closes #xxx` 或 `Fixes #xxx`,合入后 Issue 自动关闭。 > 如果你的 PR 没有对应的 Issue——先反思一下:是不是跳过了上一章? --- ### 2. 填写 PR 模板 | 必填项 | 说明 | | ----------------------- | -------------- | | 做了什么(What) | 变更的具体内容 | | 为什么这样做(Why) | 动机和背景 | | 如何测试(How to test) | 验证方法 | | 关联的 Issue | `Closes #xxx` | ```markdown 请你查看当前仓库的 PR 模板,根据我本次的代码变更按照模板格式填写 PR 描述,关联 Issue #xxx,然后用 gh pr create 命令创建 PR。 ``` --- ### 3. 自己先 Review 一遍 > **这是最重要的一步。** AI 生成的代码,你必须**逐行看一遍**。 你不一定需要懂每一行逻辑,但至少要能回答这三个问题: 1. **改了什么文件?** 为什么是这些文件? 2. **有没有改到不该改的地方?**(AI 可能顺手"优化"了无关代码) 3. **提交信息**(commit message)是否清晰? > 三个问题答不上来 = review 没到位 = **请不要提交**。 --- ### 4. 保持 PR 小而聚焦 | 正确做法 | 错误做法 | | ---------------------- | ------------------------------------ | | 一个 PR = 只修一个 bug | 一个 PR = 修 bug + 重构 + 改文档错字 | | 多个功能拆成多个小 PR | 全部塞进一个 PR | --- > **总结**:Issue -> 模板 -> 关联 -> Review -> 小步提交,串联起来才是一个**正正好的 PR**。 --- ## 题外话 虽是题外话,但这恰恰是软件工程之外**最重要的常识**——参与社区合作的隐性知识前提,或者说一种默契的社交礼仪。 > 每个人的容忍程度是有限的,大部分人可能**一次机会也不会给犯错的人**。 --- ### 你是来提意见的,不是来搞事情的 有些人在 Issue 里留言吐槽,不附带任何错误信息,只是一味地说"这个不好那个不好"。 | Issue 是议事的地方 | Issue 不是发泄情绪的地方 | | ------------------------ | ------------------------ | | 附上复现步骤 | 纯情绪输出 | | 说明期望行为 vs 实际行为 | "这个真垃圾" | | 提供环境信息 | 无凭无据 | > 一个好的 Issue(哪怕只是提 bug)必须包含:**复现步骤、期望行为、实际行为、环境信息**。缺了这些,维护者想帮你也无从下手。而且社区是有记忆的——你在 A 仓库的不当言行,可能让你在 B 仓库也寸步难行。 --- ### 不要胡乱 PR | 场景 | 建议 | | ------------------------ | ----------------------------- | | 有明确 Issue 且被 assign | 放心提 PR | | 目的明确、表述充分 | 可以直接 PR(不强制先 Issue) | | 新人、不确定、没把握 | 先 Issue 后 PR,证明态度 | > "先 Issue 后 PR"不是金科玉律,但对于新人来说,遵守这条不成文的规矩至少能证明你是认真的——不至于一上来就被喷。 --- ### 说人话 这一条看起来最简单,但 AI 时代下反而成了**重灾区**。 > 很多新人把 AI 生成的回复——充斥着"很高兴收到您的反馈!我将深入分析……"之类的套话——**原封不动**地粘到 Issue 或 PR 里。 请记住:**Issue 和 PR 是人与人之间的交流**,你面对的是另一个真实的维护者,不是 AI。 你应当: - 删掉 AI 生成的废话套话,只保留实质内容 - 用自己的话重新组织一遍——这个过程本身就是你是否真正理解了问题的**试金石** - 你自己都看不懂 AI 写的内容?**就不要发出去** --- > **一句话总结**:用 AI 辅助你思考,而不是让 AI 代替你思考。**你才是那个发 PR 的人。** --- 欢迎关注我 [github:xingwangzhe](https://github.com/xingwangzhe) ![Metrics](https://metrics.lecoq.io/xingwangzhe) --- ## 解决QQ浏览器等魔改内核下SVG背景图颜色异常变白的问题 URL: https://xingwangzhe.fun/posts/fix-svg-background-forcedark-whitening/ License: CC-BY-NC-SA-4.0 ![SVG背景变白修复指南](/images/svg-darkmode-fix/cover.webp) ## 一个好几个月前在 QQ 里发现的 Bug 大概几个月前,我在 **QQ 上把自己的博客链接分享给朋友**。朋友点开后说"你这个网站背景怎么全是白的?"我当时还以为他在开玩笑——我精心挑的 42 张 SVG 图案当背景,怎么可能是白的? 结果我自己在 QQ 里点开链接——确实全白了。QQ 内置浏览器(X5 内核 WebView)把整站的 SVG 背景图案**全部渲染成了白色**,图案的黑色线条像是被人用漂白剂洗过一样。 我的第一反应不是怀疑自己的代码。我用手机上的 **Firefox** 打开 ——正常。**Edge** ——正常。就连 **Chrome** 也是好的。唯独在 QQ 内置浏览器和夸克这种第三方 App 里渲染出来是白的。 这就很明确了——不是我的 CSS 写错了,是**这些 App 内置的魔改内核搞的鬼**。如果是标准 Chromium 的 Bug,Chrome 原版也应该复现。 不过当时手头有别的事,这个问题就被搁置了。一直到今天才想起来,不过这次我换了种思路——不是自己闷头调试,而是**让 AI 帮我把魔改内核的具体技术细节搜出来**,然后根据搜到的信息来修复。 > 本文中关于魔改内核的反色层级、Chromium 的 force-dark 源码实现等技术细节,均来自 AI 搜索结果。我只是把问题的发现过程和排查思路用自己的话整理出来。 ## 为什么一定是魔改内核的锅 AI 帮我搜到的信息印证了几个月前我的直觉判断。国内主流移动浏览器的内核全是基于 Chromium 或 WebKit 的**魔改版本**,它们在标准内核之上加了自己的"强制暗色模式": | 浏览器 | 内核代号 | 魔改基础 | 强制暗色模式实现方式 | | -------------------- | ---------------- | --------------------------- | ------------------------------------- | | **QQ 浏览器** | X5 内核 | Chromium(版本滞后的 fork) | 合成器层注入 `filter: invert()` | | **夸克浏览器** | 夸克自研渲染引擎 | Blink + 自研暗色引擎 | CIELAB 空间色彩映射 + 选择性反转 | | **UC 浏览器** | U3 内核 | WebKit 魔改分支 | Viewport 级反色滤镜 | | **360 浏览器** | 极速/兼容双核 | Chromium + Trident | 极速模式下走 Chromium 原生 force-dark | | **搜狗浏览器** | 高速内核 | Chromium 魔改 | 类似 QQ X5 的合成器层反色 | | **华为浏览器** | 华为 WebView | Chromium + HMS 覆盖层 | Android WebView Algorithmic Darkening | | **小米 MIUI 浏览器** | 系统 WebView | Chromium + MIUI 注入 | 系统级强制暗色(勾选"强制深色"后) | | **微信内置浏览器** | X5 WebView | 腾讯魔改 Chromium | 跟随系统暗色模式,自定义反色策略 | 这解释了为什么手机上的 Firefox / Edge / Chrome 原版都正常——它们走的是标准渲染管线,没有被注入额外的强制暗色逻辑。 ## 魔改内核的三个"反色层级" 通过 AI 搜索 Chromium 源码和 StackOverflow 上的相关讨论,我发现魔改内核实现强制暗色模式时会按"激进程度"分三个层级: | 层级 | 名称 | 实现方式 | 激进程度 | 采用方 | | ---- | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | -------- | ------------------------------------------------- | | 1 | CSS 层 | 监听 `@media (prefers-color-scheme: dark)`,替换颜色变量;仅影响声明了 `color-scheme` 的元素 | 温和 | 标准浏览器 | | 2 | 样式计算层 | hook Blink 的 StyleResolver,对所有计算出的颜色值做 CIELAB 空间映射:`#000` 映射到暗色空间变成非纯黑,`#fff` 保持白色保护可读性 | 中等 | Chromium 原版 `chrome://flags/#enable-force-dark` | | 3 | 合成器层 | 在 Compositor 线程上对整个页面注入 `filter: invert(1) hue-rotate(180deg)`,对所有像素无条件反转,再对 `` `