我在浏览器里练了一个 792 参数的大模型,它学会了补全 JavaScript

全部代码不到 200 行,零依赖,跑在浏览器里。loss 从 3.69 掉到 0.70 用了不到 1 秒,之后它能一个字一个字给你补出合法代码------也能一本正经地补出语法正确、语义错误的代码 。

练完这一个,你会对三件事有手感:它为什么会胡说、为什么记不住你说的话、为什么同一个问题两次答案不一样。

TL;DR(赶时间的话,看这七条就够)

  1. 大模型只做一件事:猜下一个 token。 一个字一个字往外接,接出去就收不回来------它没有全文草稿。
  2. 全部机制四步:查表(词 → 坐标)→ 池化(前缀 → 一个向量)→ 点积(和词表里每个词比)→ softmax(变成概率)。没有第五步。
  3. 训练里没有规则,只有"猜错了就往回收一点":交叉熵梯度 + Adam。参数是 792 个浮点数,知识是"这些数停在哪个位置"这件事本身。
  4. 我把它缩到 40 个词 / 166 条样本 / 792 个参数 ,浏览器里 1 秒练完。它学会补全 JavaScript,也会补出语法正确、语义错误的代码。
  5. 三个实测结论:学习率 0.12 就发散(loss 比没训练还差);d=2 学不动、d=16 不再变好;注意力在小数据上不一定赢得了求平均。
  6. 它胡说,是因为它只被"像不像"打分过,从没被"对不对"打分过。它记不住,是因为上下文就是一次数组切片。
  7. 我踩的最贵的一个坑:Adam 会掩盖梯度 bug------梯度写错了 loss 照样掉。抓出它只要 20 行代码,见 §11。

本文目录

  1. 词表:模型眼里没有字符串,只有下标
  2. 嵌入:Map<string, number[]>,语义 = 坐标
  3. 前向传播:一次池化 + 一次点积 + 一次 softmax
  4. 训练:交叉熵 + 解析梯度 + Adam(三个旋钮)
  5. 给它跑单测:它到底学会了什么
  6. 注意力:我第一版为什么是错的
  7. 生成与温度:为什么两次答案不一样
  8. 幻觉:它练的是"像",从来不是"对"
  9. 上下文窗口:它没有"记得你"这回事
  10. 把 792 换成 80 亿
  11. 我踩的坑(含那段抓出 bug 的梯度校验代码)
  12. 全部代码与复现

0. 为什么要自己练一个

关于大模型原理的文章,绝大多数停在比喻上:"像大脑""像图书馆""像接龙"。比喻帮你入门,也帮你停在门口------因为你始终不知道那些东西具体是什么。

所以我把它缩到不能再小:40 个词、166 条训练样本、792 个参数。小到你今天就能读完全部源码,小到你能亲手改超参看它变好变坏。

先给结论,后面全是在证明这句话:

大模型没有第二种数学。 就是查表、点积、softmax,加一个 while 循环。几十亿参数的模型和这 792 个参数之间,只差规模。

1. 词表:模型眼里没有字符串,只有下标

语料是 20 行短 JS。为什么用代码不用中文?因为你对代码对不对一眼就有判断,不需要我替你解释。

js 复制代码
const CORPUS = [
  'const x = 1 ;', 'const y = 1 ;', 'const z = 1 ;',
  'let a = 2 ;', 'let b = 2 ;', 'let c = 2 ;',
  'const f = ( ) => 1 ;', 'const h = ( ) => 1 ;', 'let g = ( ) => 2 ;', 'let k = ( ) => 2 ;',
  'function add ( a , b ) { return a ; }', 'function sub ( a , b ) { return b ; }',
  'function mul ( a , b ) { return a ; }',
  'async function run ( ) { await go ( ) ; }', 'async function load ( ) { await get ( ) ; }',
  'async function pull ( ) { await net ( ) ; }',
  'if ( x ) { return 1 ; }', 'if ( a ) { return 2 ; }',
  'console . log ( x ) ;', 'console . log ( 1 ) ;'
];
const tokenize = line => line.trim().split(/\s+/);

function buildVocab(corpus) {
  const set = new Set(['<s>']);                     // <s> = 一行的开头
  corpus.forEach(l => tokenize(l).forEach(t => set.add(t)));
  const words = [...set];
  return { words, id: Object.fromEntries(words.map((w, i) => [w, i])) };
}

切完是 40 项词表 。从这一刻起,模型内部再没有"字符串"这个概念,只有 0..39 的下标。const 是 1,await 是 30------跟你把类名映射成 CSS 变量是一个意思:下标只是门牌号,不含任何意义。7 号不比 6 号大。

2. 嵌入:Map<string, number[]>,语义 = 坐标

门牌号没法计算,所以给每个词发一组坐标。这就是"词向量",没有更神秘的解释。

js 复制代码
const MAXP = 14;                                   // 位置数:最长一行 12 个 token,加 <s> 留一格余量

function makeModel(V, d, rnd) {
  const p = {
    E: new Float64Array(V * d),                    // 输入嵌入:词 → 坐标
    W: new Float64Array(V * d),                    // 输出嵌入:每个词作为"答案"时的坐标
    P: new Float64Array(MAXP * d),                 // 位置嵌入
    b: new Float64Array(V),                        // 输出偏置(先验频率)
  };
  const s = 1.2 / Math.sqrt(d);
  for (const k of ['E', 'W', 'P'])
    for (let i = 0; i < p[k].length; i++) p[k][i] = rnd() * 2 * s - s;
  return { V, d, useAttn: true, p, m: new Map(), v: new Map(), t: 0 };
}

d = 8 时参数量是 40×8×2 + 14×8 + 40 = 792。这就是全部可训练的东西:792 个浮点数。

现在它们是随机数。有个很实用的自检:随机初始化时 loss 应该等于 ln(词表大小),也就是"完全均匀地瞎猜"的理论值。

理论 我实测
初始 loss ln(40) = 3.689 3.690

对不上,就说明代码错了。这个检查花 5 秒,能救你一晚上。

3. 前向传播:一次池化 + 一次点积 + 一次 softmax

给它前缀 const x,问下一个 token 是什么。三步:

js 复制代码
/* 把前缀池化成一个向量 x̄ */
function pool(m, ctx) {
  const { d, p } = m, n = ctx.length, xb = new Float64Array(d), e = [];
  for (let i = 0; i < n; i++) {                    // e_i = 词嵌入 + 位置嵌入
    const t = ctx[i], v = new Float64Array(d);
    for (let k = 0; k < d; k++) v[k] = p.E[t * d + k] + p.P[i * d + k];
    e.push(v);
  }
  if (m.useAttn) {                                 // 注意力池化:query 取最后一个 token
    const q = e[n - 1], s = new Float64Array(n);
    let mx = -Infinity, Z = 0, al;
    for (let i = 0; i < n; i++) {
      let a = 0; for (let k = 0; k < d; k++) a += e[i][k] * q[k];
      s[i] = a / Math.sqrt(d); if (s[i] > mx) mx = s[i];
    }
    al = new Float64Array(n);
    for (let i = 0; i < n; i++) { al[i] = Math.exp(s[i] - mx); Z += al[i]; }
    for (let i = 0; i < n; i++) al[i] /= Z;
    for (let i = 0; i < n; i++) for (let k = 0; k < d; k++) xb[k] += al[i] * e[i][k];
    m._al = al;
  } else {                                         // mean 池化:一视同仁地平均
    for (let i = 0; i < n; i++) for (let k = 0; k < d; k++) xb[k] += e[i][k] / n;
    m._al = null;
  }
  m._xb = xb; return xb;
}

/* 和每个词的输出向量点积 → softmax → 概率 */
function forward(m, ctx) {
  const xb = pool(m, ctx), { d, p, V } = m;
  const logits = new Float64Array(V); let mx = -Infinity, Z = 0;
  for (let j = 0; j < V; j++) {
    let a = 0; for (let k = 0; k < d; k++) a += xb[k] * p.W[j * d + k];
    logits[j] = a / Math.sqrt(d) + p.b[j];
    if (logits[j] > mx) mx = logits[j];
  }
  const pr = new Float64Array(V);
  for (let j = 0; j < V; j++) { pr[j] = Math.exp(logits[j] - mx); Z += pr[j]; }
  for (let j = 0; j < V; j++) pr[j] /= Z;
  m._logits = logits; m._pr = pr; return pr;
}

数一数运算量:40 次 d 维点积。没有 if,没有分支,没有查表逻辑。你在页面上看到的每一张概率榜,都是这段代码现算的,每个数字都能拿计算器复核。

4. 训练:交叉熵 + 解析梯度 + Adam

没人给它写任何规则。方法朴素到令人失望:把真句子的下一个 token 告诉它,跟它猜的比一比,错得越离谱,回去改得越多。

js 复制代码
/* 节选:E/P 的梯度回传部分省略,完整实现见 tiny-llm.html 底部状态栏 → model.js → grads */
function trainStep(m, ctx, tgt, lr) {
  const pr = forward(m, ctx), { d, p, V } = m;
  const loss = -Math.log(Math.max(pr[tgt], 1e-12));
  /* 交叉熵过 softmax 的梯度,干净得可疑:dz = p − onehot */
  const gx = new Float64Array(d), gW = new Float64Array(V * d), gb = new Float64Array(V);
  for (let j = 0; j < V; j++) {
    const z = pr[j] - (j === tgt ? 1 : 0);
    gb[j] = z;
    for (let k = 0; k < d; k++) {
      gW[j * d + k] = z * m._xb[k] / Math.sqrt(d);
      gx[k] += z * p.W[j * d + k] / Math.sqrt(d);
    }
  }
  /* ...再把 gx 回传到 E 和 P(mean 是除以 n,attention 多一段 softmax 的导数)*/
  m.t += 1;
  adamStep(m.p.E, 'E', gE, lr, m.t, m.m, m.v);
  adamStep(m.p.W, 'W', gW, lr, m.t, m.m, m.v);
  adamStep(m.p.P, 'P', gP, lr, m.t, m.m, m.v);
  adamStep(m.p.b, 'b', gb, lr, m.t, m.m, m.v);
  return loss;
}

优化器用的是 Adam------大模型用的也是它(换成 AdamW,差别是权重衰减)。

练 1500 步,浏览器里不到 1 秒。三个旋钮,每一个都能烧到你熟悉的经验:

学习率(d=8, 1500 步)

lr 0.01 0.03 0.06 0.12 0.3 0.8
loss 0.767 0.698 0.849 3.110 5.732 10.777

0.12 就比"根本没训练"(3.69)还差。步子太大不是快,是散。

维度 d

d 2 3 4 8 16
loss 1.552 1.039 0.823 0.698 0.691
单测 0/8 3/8 4/8 4/8 4/8

2 维根本学不动,16 维几乎不再变好。表达能力是有预算的,跟 bundle size 一样要换。

步数

步数 100 300 600 1000 1500
loss 2.653 1.424 0.960 0.785 0.698
它写的 const x → = ; ; = 2 ; = 2 ; = ( ) { await net ( ) ; 同左

5. 给它跑单测:它到底学会了什么

loss 是一个笼统的数字。要知道它学到了什么,得按规则出测试 ------这条你最熟。我在语料里埋了两条长程规则:

  1. async 开头的行,6 个 token 之后 必须出现 await
  2. const 开头的行以 1 ; 结尾,let 开头的以 2 ; 结尾

每个用例给它一个真实前缀,看 argmax 出什么、有多自信:

规则 期望 attention mean
async function run ( ) { → await ✓ 97% ✓ 64%
async function load ( ) { → await ✓ 97% ✓ 66%
const x = → 1 ✗ 5%(答 () ✓ 55%
const z = → 1 ✗ 5%(答 () ✓ 78%
let a = → 2 ✗ 5%(答 () ✓ 90%
let c = → 2 ✗ 5%(答 () ✓ 95%
function → 函数名 ✓ 47% ✓ 45%
{ → return ✓ 100% ✓ 71%
总 loss 0.698 0.791

注意注意力输了单测却赢了总 loss。 拆开看才清楚:

位置 attention mean 谁赢
await(回看 6 个 token) 0.028 0.412 注意力赢 14.5 倍
1(回看 3 个 token) 2.165 1.175 平均反而赢

这不是"注意力不行",这是我第一版注意力的写法有问题------见下一节。

6. 注意力:我第一版为什么是错的

看上面那段 pool():我的 query 直接取了最后一个 token:

js 复制代码
const q = e[n - 1];
s[i] = e[i] · q / √d;

问题立刻出来了:e[n-1] · e[n-1] 天然是最大的分数之一------它永远给自己打最高分 。于是注意力退化成"只盯最后一个 token"。const x = 之后它盯着 =,把语料里 const f = ( ) => 1 ; 那行的记忆搬出来,答了个 (。

这正好解释了真 transformer 为什么需要 W_q 和 W_k:得把"我在找什么"和"我是什么"分开。

我照做了------加上两个 d×d 投影矩阵,反传多写 15 行。结果:

模式 1500 步 loss(种子 1 / 7 / 23 / 101)
mean(求平均) 0.729 / 0.791 / 0.785 / 0.702
attention(q = k = 嵌入) 0.690 / 0.698 / 0.683 / 0.705
attention + W_q/W_k 1.207 / 2.039 / 1.800 / 1.781

第三行是照真 transformer 补上两个 d×d 投影矩阵的结果:多 128 个参数(+16%),loss 却从 0.7 量级退到 1.2~2.0,四个种子全都不如求平均。 166 条样本喂不饱多出来的那批参数------它需要更多数据去"学会怎么分工",而我没给它。

所以这一节的结论不是"注意力没用",而是:架构变复杂 ≠ 变好,中间隔着一个"数据够不够喂"的门槛。

所以结论不是"注意力更好",而是:

注意力的价值 = 要回看多远 × 有多少数据喂得起它。小任务上,求平均是个很强的基线。这不是大模型的例外,这就是同一件事。

7. 生成与温度:为什么两次答案不一样

生成就是一个 while 循环,全部代码如下:

js 复制代码
function generate(m, words, startTokens, maxLen, temp, rnd) {
  const ctx = startTokens.slice(), out = [];
  for (let i = 0; i < maxLen; i++) {
    const k = sampleNext(m, ctx, temp, rnd);       // 内部就是 forward + 按概率抽样
    out.push(words[k]); ctx.push(k);               // 刚生成的字接回输入
    if (words[k] === ';' && i > 1) break;
  }
  return out;
}

三个必须记住的事实:

  1. 纯串行。 生成 500 个 token 就是 500 次完整前向,一次都不能并行。所以它慢、所以它贵、所以有了 KV cache 这些补丁。
  2. 写出去收不回来。 它没有提纲,不能改稿,只能继续往下猜。
  3. 温度就是给 logits 除一个数 :sc = logits / temperature。

同一个模型(d=8,练了 1500 步)、同一起点,只换温度,各接三次:

起点(榜首概率) 温度 0.05 温度 0.5 温度 2.0
function add ( a , b )({ 99.4%) { return a ; ×3 全同 { return a ; ×3 全同 { c a ; / { return b ; / { return ) ;
async function load(( 99.5%) ( ) { await net ( ) ; ×3 全同 ( x ) ; / ( ) { await get... / ( ) { await go... ( x b ; / ( ) { await => a return ; / ( ) { run b ) ;
const x(= 98.4%) = ( ) { await net ( ) ; ×3 全同 = ( ) { await get... / = ( ) { await go... / = ( ) { await get... = 1 ; / = ( ) , 2 ; / 1 ( , b ;

低温三次完全相同------因为每次都在拿第一名 。温度拉到 2.0,句子开始散架({ return ) ; 这种根本没法运行)。

但这里有个我没想到、值得单独说的现象:const x 这一行,贪心(温度 0.05)三次都给出 = ( ) { await net ( ) ;------这是错的 (const x 后面该接 1 ;);而温度 2.0 的三次里,有一次是 = 1 ;,恰好是正确答案。

所以温度高不只是"更容易错"。贪心会锁死在"最顺"的那条路上,而顺,在这个模型里恰好不等于对。加一点随机,反而有机会跳出那条错误的吸引子。

这也是为什么工程上会看到"同一个 prompt 采样 5 次再挑最好的"(self-consistency)------它治的就是贪心锁死这个病。

这就是你问两次答案不同的全部原因 :sc = logits / temperature,一行。

8. 幻觉:它练的是"像",从来不是"对"

回头看第 4 节:它全部的得分只来自"下一个 token 猜得像不像"。没有任何一次,有人检查过它说的是不是真的。

所以它会把不存在的续写接得很顺。让它从 const x 起头,真实输出是:

复制代码
const x = ( ) { await net ( ) ;

语法顺得像真的,语义完全不对------它把 async 那几行的模板套到了 const 行上。我写了个结构检查器,当场判它 括号没配对。

它胡说的时候,和它写对的时候,用的是同一套机制、同一个 loss。 所以:说得越流畅,不代表越可靠。关键的事自己核;能被查到的那部分,是外面另接了检索。

9. 上下文窗口:它没有"记得你"这回事

每次回答,模型看见的只有窗口里那一段 token 。窗口外的字不是"忘了"------是压根没被传进去,跟没说过一样。

复制代码
[要 虚 拟 滚 动] 因 为 数 据 有 5 万 条 好 的 我 按 你 说 的 改 现 在 写 两 千 字
 ↑ 滑出左边缘 = 这一轮不存在

这解释了那件让人恼火的事:你第一轮说"周报要短",到第四十轮它写了两千字。不是不听话,是那四个字早就滑出切片了。 所谓"它记得你的偏好",是有人把旧对话重新塞回窗口再读一遍;所谓"长期记忆",是外面另接了一个数据库。

10. 把 792 换成 80 亿

本页模型 Llama 3.1 8B
参数 792 8,000,000,000
词表 40 128,256(BPE)
维度 d 2--16 4096
训练量 166 条 × 1500 步 ~15 万亿 token
优化器 Adam AdamW
核心操作 点积 + softmax 点积 + softmax

(右边一列刻意取同一个模型的公开数字,免得各挑一个最漂亮的凑一起。)

最后一行一样,就变不出第二种东西。真模型多出来的多头注意力、残差、LayerNorm、位置编码变体、KV cache、指令微调与 RLHF,都是在这 200 行之上叠的工程,不是另一套数学。

打个你熟悉的比方:RLHF 就像给一个已经能跑的 npm 包接上 lint + prettier------不是让它更聪明,是让它说人话。

11. 我踩的坑(这一节才是重点)

  1. Adam 会掩盖 bug。 我第一版梯度漏了 1/√d,解析梯度是正确值的 √d 倍(在 d=4 下做校验时正好差 2 倍)------loss 照样漂亮地往下掉。是数值梯度校验(把某个参数 ±ε,比较数值差分与解析梯度)把它抓出来的。教训:loss 曲线下降 ≠ 梯度写对了。
  2. 我的单测本身有 bug。 测试上下文漏了 <s>,位置向量整体错位一格,于是"attention 0/8"测的是我的 bug 不是模型。修好之后才有了第 6 节那个结论。差点就把一个错误结论写进文章。
  3. 无参数注意力会退化成"只盯自己"。 见第 6 节。
  4. 第一版架构根本学不会。 没有位置向量、没有输出偏置、用 SGD------attention 比 mean 差,生成退化成 ( ( ( (。加了三样东西才成立。
  5. Pillow 的 alpha 是替换不是合成。 我用 Python 逐帧渲染讲解视频时,10% 的淡光晕在 convert('RGB') 之后变成 100% 实色,把整张语义地图盖掉了。缩略图上看着像"颜色深了点",很像设计选择。取样像素才暴露:px[600,640] == (14,124,116)。
  6. Consolas 没有中文字形。 视频里所有等宽中文渲染成豆腐块。

11.1 那段抓出 bug 的 20 行代码

第 1 条值得单独展开,因为它是那种**"跑起来一切正常"的错误**。

原理很朴素:把某个参数加一个极小的 ε,看 loss 变化多少,得到数值梯度 ;再和代码算出的解析梯度比。两者不一致,反传就写错了。

js 复制代码
/* 数值梯度校验:解析梯度对不对,这 20 行说了算 */
function gradCheck(m, ctx, tgt, keys, pick, eps = 1e-6) {
  const g = grads(m, ctx, tgt).g;                 // 解析梯度
  const lossOf = () => -Math.log(Math.max(forward(m, ctx)[tgt], 1e-12));
  let worst = 0, where = '';
  for (const key of keys) {                       // ['E','W','b','Wq','Wk']
    const arr = m.p[key];
    for (let t = 0; t < 12; t++) {                // 每个张量抽查 12 个点
      const i = pick(arr.length), save = arr[i];
      arr[i] = save + eps; const lp = lossOf();
      arr[i] = save - eps; const lm = lossOf();
      arr[i] = save;                              // 还原,别污染模型
      const num = (lp - lm) / (2 * eps), ana = g[key][i];
      const err = Math.abs(num - ana) / Math.max(1e-4, Math.abs(num) + Math.abs(ana));
      if (err > worst) { worst = err; where = `${key}[${i}] 数值 ${num.toFixed(6)} 解析 ${ana.toFixed(6)}`; }
    }
  }
  return { worst, where };
}

它的三次真实输出(不是我编的示例):

轮次 mean attention(q=k) attention + Wq/Wk
初版 误差 3.3e-1 <br>W[38] 数值 -0.000743 解析 -0.001486 3.3e-1 6.6e-1
补上 1/√d 后 1.2e-7 ✓<br>W[46] 数值 -0.000713 解析 -0.000713 1.2e-1 1.3e-1

第一行就是那个 bug:解析值恰好是数值值的 2 倍 ,而 √4 = 2(校验跑在 d=4 上)------我漏的就是 1/√d。

而与此同时,训练脚本里的 loss 一路漂亮地往下掉。如果我只看 loss 曲线,这个 bug 会跟我到永远。

至于 attention 那两列剩下的 1.2e-1:一部分是我故意不做回传的 query 路径(代码里注释了),一部分我没继续追------因为那个分支的 loss 本来就比求平均差,我直接放弃了它。梯度校验告诉你哪里不是精确解 ,但要不要追下去是个成本决策;只是这次它抓到的那个 1/√d,正好在"必须追"的那一档。

建议:任何自己手写反传的代码,把这 20 行抄进去跑一遍。它比任何单测都便宜,而且专治"看起来能跑"。

12. 全部代码与复现

我做了三个东西,同源:

文件 是什么
tiny-llm.html 这一篇的可玩版 :模型真跑在浏览器里,改超参、看坐标聚拢、跑 A/B、控制台里 tiny.model() 直接拿对象。单文件 196KB,零联网
llm-explained.html 给完全不懂的人看的滚动动画版,零公式
llm-principle.mp4 3:45 视频版,1920×1080,纯代码逐帧渲染,22 项验收全通过

渲染与验收:

bash 复制代码
python render.py still 4250    # 任意时间码单帧出图,可复现
python render.py full          # 全量 6750 帧 → mp4(约 2 分钟)
python verify.py               # 22 项验收:时长/帧率/无黑帧/无静止/安全区/确定性

如果你也打算写一遍:先别画界面,先在 Node 里证明它能学会。 我第一版跑出来 attention 比 mean 差、生成全是 ( ( ( (,那才是真正该害怕的时刻------不是没 bug,是 bug 长得像结果。


文中所有数字都是现场算出来的,唯一写死的是语料。有错请指出。

相关推荐
hhhjhl1 小时前
Yup 跨端三方库仓颉适配全流程
java·开发语言
柳杉1 小时前
用 Codex 完成智慧高速 3D 可视化大屏,从 18 张行业设计稿到可交互的运行监测中心
前端·ai编程·数据可视化
运维行者_2 小时前
网络监控工具怎么选?从设备监控到智能运维的能力清单
运维·服务器·开发语言·网络·人工智能·支持向量机·php
鶴哥只手遮天2 小时前
jQuery 4.0 源码架构与工程实践-第 01 章 · 全景与工程结构:一个 20 岁库的自省
前端·javascript·jquery
漫步的猪2 小时前
# 告别混乱与付费墙!Linkit:完全免费、本地优先的开源 AI 智能书签与收藏夹管理神器
人工智能·ai·开源·react·免费·书签·收藏夹
效率工作实验室2 小时前
搭建AI智能体应用,主流智能体开发平台应关注哪些核心能力
ai·agent·智能体开发平台·核心能力·ai智能体应用
“AI国潮设计-小江”2 小时前
【AIGC实战】Python+SDXL打造“潮汕英歌舞IP”万能立牌插件,批量生成“发财元宝蛋糕”视觉营销方案(附源码)
开发语言·人工智能·python·prompt·aigc
孟健3 小时前
Kimi K3.1 Pro 编程实测:3个月27万行代码,每年1.7万预算下如何9分钟修好顽固Bug
前端·开源·ai编程
sbjdhjd3 小时前
AI产业周度动态:从“更强模型”走向“可执行、可治理、可计价”的系统竞争 (2026年10月19日—10月25日)
人工智能·笔记·ai·开源·gemini·信息差·claude code