想象一位美食家正在建造一座“味道图书馆”。每来一道新菜,他不用背菜谱,只需记下几个数字:甜度 3、辣度 7、酸度 2、咸度 5……于是“鱼香肉丝”和“宫保鸡丁”在这张味道地图上挨得很近,而“红烧肉”身边站的是“东坡肉”。客人说“想找点酸辣口的”,他看一眼数字,几秒就能报出最像的几道菜。代码嵌入(Code Embedding)做的正是这件事:把一段代码当成一道菜,把它的“语义味道”翻译成一串数字——向量。机器看不懂代码,但机器天生擅长算数字之间的距离。一旦代码变成向量,语义相近的代码就会在向量空间里聚成团,于是代码搜索、克隆检测、缺陷预测这些“代码智能”应用纷纷成为可能。这篇文章会带你从最基础的 token 向量出发,一路走到 CodeBERT、UniXcoder 这些预训练模型,最后学会如何评价和使用嵌入——就像那位美食家,从给葱姜蒜建档案,到成为尝遍天下的名厨。
掌握代码嵌入,你就掌握了让机器“读懂”代码的入口:它是代码搜索、克隆检测、缺陷预测等一切代码智能产品的底层地基,也是代码理解系统的核心特征工程。
可视化演示
展示代码到向量的映射过程,包含向量空间聚类分布、Token到函数级嵌入层级聚合,以及代码搜索相似度匹配应用。
本节配有交互动效(进入视口后自动加载,约 3KB)。
向量空间:代码语义聚类 循环代码形成独立簇 排序代码语义相近 d(vec_i, vec_j) = ||e_i - e_j||^2 相似代码在向量空间中距离更近,不同类别形成可分聚类。
嵌入核心思想
假设一位资深美食家要建一座“味道图书馆”。每来一道新菜,他先记下几项味道坐标:甜度、辣度、酸度、咸度、鲜度。于是“鱼香肉丝”和“宫保鸡丁”在地图上挨得很近,“红烧肉”则和“东坡肉”挤作一团。客人说“想找点酸辣口的”,美食家看一眼坐标,几秒钟就能报出最像的那几道菜。代码嵌入做的正是这件事:把一段代码当成一道菜,把它的“语义味道”翻译成一串数字坐标——也就是向量。
这里的关键不是数字本身,而是数字之间的空间关系。两段代码语义越接近,它们的向量在空间里就越靠近;毫不相干的代码,向量会离得很远。机器看不懂源代码,但机器擅长算向量距离,所以“把代码变成向量”就等于给了机器一双“品菜”的舌头。
形式上,嵌入是一张查找表(Embedding 层)加一个聚合操作。代码先被切成 token 序列,每个 token 通过查表得到自己的 d 维向量,整段代码再取平均(或更复杂的池化),得到函数级的向量:
回到味道图书馆:单颗蒜有单颗蒜的味道档案,但一道菜的味道是全部食材味道叠加后的整体。代码向量同理——token 是食材,函数向量是整道菜。有了这套坐标,机器才能在代码的世界里“按味道找菜”。接下来我们一步步看:单个 token 的向量是怎么来的。
- 代码分词把代码切成 T 个 token,每个 token 是语义的最小单元,相当于一道菜里的“食材”。
- 查向量表每个 token 在嵌入表中查到自己的 d 维向量,就像查食材的味觉档案。
- 取平均把所有 token 向量叠加取平均,得到整段代码的向量——整道菜的“综合味道”。
Token级嵌入
味道图书馆的第一步,是给每一种食材建立独立档案:葱、姜、蒜、花椒各占一行。以后任何菜里出现“葱”,都去查“葱”那一行。token 级嵌入就是这样:给每个代码 token 分配一个固定向量,“def“ 一个向量,”sort“ 一个向量,”for” 一个向量。
那向量从哪来?Word2vec 的思路很朴素:一个词的语义由它周围的词决定。“arr“ 旁边经常出现 ”for”"range"“index“,说明它们是”搭档”;“self“ 旁边常出现 ”def”"class",它们是另一伙。模型把这些共现关系压进向量,让关系近的词向量也近。
直接看训练目标:给定中心词 \(w_t\),预测窗口内某个邻居词出现的概率。这个概率用 softmax 定义——先让中心词向量与候选词向量做内积打分,再除以词表里所有词的得分之和,变成合法概率:
食材档案有个大缺陷:“葱”只有一份档案,可葱在葱爆羊肉里是爆香料,在葱油饼里是主食灵魂,在葱烧海参里又是去腥担当。固定 token 向量认不出这些区别。要真正读“懂”代码,厨师必须看上下文——这就是下一节的上下文嵌入。
- 上下文窗口以 \(w_t\) 为中心,窗口内 \(\pm c\) 的词都是它的“邻居”(共现词),是预测的目标。
- 相似度打分用向量内积衡量中心词与邻居词的相似程度,内积越大表示越“搭”。
- 归一化成概率除以词表所有词的得分之和,把任意打分变成 0 到 1 之间的概率分布。
上下文嵌入
有经验的大厨从不单看食材档案。同样是“葱”,他会结合这道菜的做法、火候、搭配来判断它在其中扮演什么角色。上下文嵌入的思路一模一样:同一个 token 在不同代码片段里,应该得到不同向量,因为它的“角色”不同。
实现这种动态表示,最早靠循环神经网络(RNN)。RNN 从左到右逐个读 token,每读一个,就把前文的摘要藏在隐藏状态里传给下一步。但只读一遍只能看到“前文”,于是又加了反向的 LSTM 从右往左再读一遍,两个方向的表示拼接起来,每个 token 就成了“看过完整上下文”的向量。
第 t 个 token 的最终表示,是正向隐状态和反向隐状态拼接的结果:
这样的动态向量,让“查找”在“线性查找”和“二分查找”两个函数里呈现出微妙不同的语义位置。不过 RNN 读长代码容易“记性差”,于是新一代厨师——Transformer 登场。CodeBERT 就是一位用 Transformer 当舌头、专门品代码的名厨。
- 正向编码从左到右读代码,第 t 步的正向隐状态携带“前文”的信息。
- 反向编码从右往左再来一遍,携带“后文”的信息。
- 双向拼接把两个方向的隐状态拼接,得到“看过完整上下文”的表示。
CodeBERT原理
光靠一道菜本身,很难判断它做得是否地道。名厨还会看看菜谱说明、配料表、成品照片。CodeBERT 训练时就“双管齐下”:一边看源代码,一边看与它配套的自然语言文档(比如注释、README)。这两路信息互相印证,嵌入的质量自然更高。
CodeBERT 用 Transformer 编码器把代码和文档一起读进去。输入按特殊格式拼接:代码片段、分隔符、文档描述。模型随机遮住其中一些 token(掩码),再从剩余内容推断被遮住的词。为了猜对,它必须理解代码与文档的对应关系——这正是语义嵌入的来源。
掩码语言模型(MLM)的预训练目标可以写成一个条件概率:给定剩余代码和文档,最大化被遮 token 出现的对数概率:
CodeBERT 这位名厨品过的菜数以亿计,靠“完形填空”练出了敏锐的语义味觉。但它有个短板:只会“一种菜系”——单语言,也不太看菜刀工(语法结构)。于是更全能的 UniXcoder 出现了。
- 训练样本每对样本由代码 c 和它的文档 d 组成,像“菜”配“菜谱”。
- 掩码操作随机遮住代码里的某个 token m,让它从输入里“消失”。
- 条件预测让模型根据剩余的代码和文档,预测被遮住的 token,最大化其对数概率。
UniXcoder扩展
UniXcoder 是一位“国际名厨”:既会 Python、Java、JavaScript 等多种“菜系”,还懂“刀工”——代码的语法树(AST)。他上菜时会同时参考食材、做法、摆盘结构,所以即使面对陌生菜系,也能凭结构规律猜出味道。
UniXcoder 在 CodeBERT 之上做了三个升级:一是把多语言代码放进同一个模型训练,让不同语言的相似语义共享同一片向量空间;二是显式注入 AST 结构信息,让模型看到“谁嵌套在谁里面”;三是多任务预训练,除了掩码预测,还加上对比学习和去噪目标。
多任务目标用加权求和的方式组合:
有了 UniXcoder,“在 Python 里搜一个 Java 风格的排序实现”不再是梦——跨语言检索正是它的拿手菜。但话说回来,名厨手艺再高,端上桌好不好吃还得盲评说了算。下一节我们聊怎么评价嵌入质量。
- 掩码项保留 CodeBERT 的掩码预测目标,继续练“完形填空”的基本功。
- 对比项让相同语义的代码在向量空间里互相靠近、不同的互相远离,像“同味成团”。
- 加权求和用超参数 \(\lambda\) 控制三类目标的比重,得到最终的总损失。
嵌入质量评估
味道图书馆好不好用,不能光听厨师自夸,要办“盲评”:随机抽一道菜,让客人在整本菜单里找出最像的三道,看排名靠不靠谱。嵌入质量评估也是这个思路——用检索任务检验:给定一个查询代码,看正确的相似代码能不能排在最前面。
评估的第一步是回答“两个向量有多像”。最常用的度量是余弦相似度:只看方向、不看长度,数值在 -1 到 1 之间。1 表示方向完全一致(语义极近),0 表示垂直(无关),-1 表示相反。
余弦相似度的计算分三步:向量点积、各自模长、相除:
以检索为试金石,业界形成了标准考卷:CodeSearchNet 测自然语言搜代码,POJ-104 测相似题检索,BigCloneBench 测克隆检测。嵌入模型好不好,跑一张考卷就见分晓,比任何自吹都管用。有了经得起盲评的嵌入,真正的应用就水到渠成了。
- 点积逐维度相乘再求和,相当于把两个向量在每一维上的“合拍程度”加总。
- 模长向量自身的“长度”,等于各维度平方和再开根号。
- 夹角余弦点积除以两个模长,把结果压缩到 -1 到 1 之间,只看方向不看长短。
典型应用场景
味道地图一旦建好,就能开三家店:一是“点菜推荐”——客人描述一句口味,立刻从菜单里找出最像的菜;二是“抓复刻店”——怀疑某家店抄袭招牌菜,把两道菜丢进空间比一比距离;三是“预判翻车”——新菜还没上桌,先根据食材搭配预测它会不会被顾客差评。
对应到代码世界:代码搜索、克隆检测、缺陷预测。搜索把查询代码(或自然语言)嵌入成向量,用最近邻检索召回 Top-K;克隆检测把两个函数的向量余弦算出来,超过阈值就判为克隆;缺陷预测把函数向量喂给分类器,输出是“有缺陷/无缺陷”。
缺陷预测的模型最后一步通常用二元交叉熵损失训练:
从味道坐标到三道生意,代码嵌入这条链路环环相扣:token 向量是食材档案,上下文向量是角色判断,预训练模型是名厨的舌头,评测是盲评,应用是开店。理解了整个链路,你就掌握了代码智能的底层地基。
- 嵌入特征把函数嵌入向量作为分类器的输入特征,相当于给裁判“味道坐标”。
- 二分类预测模型用逻辑回归(sigmoid)输出一个 0 到 1 之间的缺陷概率。
- 交叉熵惩罚真实标签 \(y_i\) 是 0 或 1,预测越离谱损失越大,迫使模型学会“危险信号”。
AI 应用场景
代码语义搜索
阶段:表示学习(Embedding) + 向量检索(Retrieval)
解决的问题:传统 grep 只能按字符串匹配,搜“把数组反转并去重的函数”时,凡是用了不同写法、不同变量名的相似实现全部漏掉。用代码嵌入把全库函数变成向量,查询时用余弦相似度召回,语义相同的代码即使长得完全不一样也能被找到。
具体案例:某大型代码平台对数千个仓库离线计算函数向量、建立向量索引。开发者输入一句自然语言“从字符串里提取所有数字”,系统用 CodeBERT 编码查询后秒级返回 Top-10 相似函数,准确率比纯文本搜索提升约 40%。
克隆检测与代码合规审计
阶段:嵌入后的相似度阈值判断
解决的问题:大模型生成的代码经常与开源代码“撞车”,人工审计几万行变更不现实。将每个函数嵌入向量,计算与开源库的余弦相似度,超过阈值即触发布控告警。
具体案例:一家金融科技公司在 CI 流水线里接入克隆检测服务,每次代码合入前自动扫描 GPL/AGPL 许可证代码混入风险,上线三个月拦截了 120 多次疑似违规提交。
嵌入驱动的缺陷预测
阶段:特征工程 + 分类模型
解决的问题:传统缺陷预测依赖人工设计的复杂度指标(行数、圈复杂度),难以捕捉语义层面的风险信号。用函数嵌入向量作为特征,能保留调用关系、异常处理等语义模式,分类器更容易学到“这类写法容易出 bug”的规律。
具体案例:某交易系统对每次变更涉及的函数做缺陷风险评分,高危函数自动提醒开发补充单元测试。试点三个月,合入后缺陷率下降 22%。
Python 演示
# -*- coding: utf-8 -*-"""迷你代码嵌入演示: 用 skip-gram 把代码 token 变成向量"""import mathimport random import numpy as np random.seed(1)np.random.seed(1) corpus = [ ["def", "bubble_sort", "(", "arr", ")", ":", "for", "i", "in", "range", "(", "n", ")"], ["def", "quick_sort", "(", "arr", ")", ":", "for", "j", "in", "range", "(", "n", ")"], ["def", "binary_search", "(", "arr", "target", ")", ":", "low", "=", "0"], ["def", "linear_search", "(", "arr", "target", ")", ":", "for", "i", "in", "arr"], ["class", "TreeNode", ":", "def", "insert", "(", "self", "node", ")", ":"], ["class", "BinaryTree", ":", "def", "delete", "(", "self", "node", ")", ":"], ["def", "train", "(", "model", ")", ":", "for", "epoch", "in", "range", "(", "K", ")"], ["def", "predict", "(", "model", "x", ")", ":", "return", "model", "forward", "(", "x", ")"],] w2i = {}i2w = []for line in corpus: for w in line: if w not in w2i: w2i[w] = len(i2w) i2w.append(w) V = len(i2w)DIM = 8Wc = np.random.randn(V, DIM) * 0.1Wctx = np.random.randn(V, DIM) * 0.1WINDOW = 2LR = 0.05 def softmax(x): e = np.exp(x - np.max(x)) return e / e.sum() def train_one_epoch(): total = 0.0 for line in corpus: for t in range(len(line)): c = w2i[line[t]] h = Wc[c] lo = max(0, t - WINDOW) hi = min(len(line), t + WINDOW + 1) for j in range(lo, hi): if j == t: continue o = w2i[line[j]] probs = softmax(np.dot(Wctx, h)) grad = probs.copy() grad[o] -= 1.0 Wctx -= LR * np.outer(grad, h) Wc[c] -= LR * np.dot(Wctx.T, grad) total += -math.log(max(probs[o], 1e-8)) return total def embed_line(tokens): vec = np.zeros(DIM) count = 0 for w in tokens: if w in w2i: vec += Wc[w2i[w]] count += 1 return vec / max(count, 1) def cosine(a, b): return float(np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b) + 1e-8)) def main(): for epoch in range(20): loss = train_one_epoch() if epoch % 5 == 0: print("epoch", epoch, "loss", round(loss, 2)) query = ["def", "sort", "(", "arr", ")", ":"] qv = embed_line(query) ranked = [] for i, line in enumerate(corpus): ranked.append((cosine(qv, embed_line(line)), " ".join(line))) ranked.sort(key=lambda x: -x[0]) print("查询:", " ".join(query)) for score, text in ranked[:3]: print(round(score, 3), text) if __name__ == "__main__": main()
V = len(i2w)词表大小决定嵌入矩阵的行数,每个 token 独占一行。Wc = np.random.randn(V, DIM) * 0.1初始化“中心词”嵌入表,随机起点交给训练修正。probs = softmax(np.dot(Wctx, h))用当前向量预测中心词周围该出现哪些 token。grad[o] -= 1.0用“预测概率 - 真实标签”构造梯度,让正确邻居的概率被抬高。Wctx -= LR * np.outer(grad, h)更新上下文嵌入表,让关系近的 token 向量互相靠近。vec += Wc[w2i[w]]把函数每个 token 的向量累加,得到函数级向量。return vec / max(count, 1)取平均归一化,得到与 token 数量无关的函数向量。ranked.sort(key=lambda x: -x[0])按余弦相似度降序排列,完成“按味道找菜”的检索。
常见误区
误区很多初学者以为嵌入向量的每一维都可以解释,比如第 1 维是“甜度”、第 2 维是“辣度”,改一个维度就能控制语义。“既然叫味道坐标,那维度肯定对应具体味道吧?”
正解嵌入是分布式表示,单个维度本身没有显式语义。语义信息是“蒸馏”在整个向量空间的:单独看第 3 维的数字没有任何意义,但两个向量的整体方向关系(余弦相似度)能反映语义远近。就像一道菜的照片,单独一个像素点看不出味道,整体画面才能让你流口水。
为何常见:入门讲解为了建立直觉,常用“味道坐标”这类具象类比,学习者容易把类比当成真实结构,误以为每个维度都可解释。
误区“我用 Word2vec 把 'sort' 变成了向量,它应该已经知道 sort 是什么意思了吧?不同代码里的 sort 用同一个向量就好。”
正解Word2vec 这类固定词向量是“上下文无关”的,每个 token 只有一个向量。可 “sort“ 在 ”sort(list)“ 和 ”sort(out)“ 里语义完全不同——就像”葱”在葱爆羊肉和葱油饼里角色不同。只有上下文嵌入(如 BiLSTM、CodeBERT)才能给同一个 token 不同的动态向量。
为何常见:学习路径上先接触 Word2vec 再接触 CodeBERT,中间没有明确区分“静态”与“动态”表示,容易把两层能力混为一谈。
误区“两个向量差得越少就越相似,直接用欧氏距离不就行了吗?为什么还要算余弦?”
正解嵌入向量通常没有统一模长,不同模型训练出来的向量尺度差异很大。欧氏距离对“模长”敏感,可能把两个方向一致但模长不同的向量判为“不相似”。实际工程中先做归一化、再看余弦相似度,才稳定可比——就像比较两道菜应该看“味道方向”而不是“菜的重量”。
为何常见:中学几何里“距离”的概念先入为主,而嵌入领域里“方向相似”才是主流约定,教材如果没有强调归一化步骤就容易踩坑。
误区“直接上最大的语言模型,参数越多嵌入一定越强,小模型 CodeBERT 肯定过时了。”
正解嵌入质量的瓶颈常在于预训练目标与数据是否贴合代码任务。CodeBERT 专门用“代码+文档”双模态掩码训练,在代码检索上可能强于参数大得多但没做过代码专项预训练的通用模型。UniXcoder 强在多语言和结构感知,而不是单纯堆参数。选模型要匹配下游任务和数据规模。
为何常见:近两年大模型热潮带来“参数崇拜”,忽略了任务与预训练目标匹配这个更本质的因素。
面试题
Q1. 代码嵌入的核心目标是什么?
- A.把代码字符串压缩成更短的字符串
- B.把代码映射成稠密向量并保留语义← 正确答案
- C.把代码翻译成另一种编程语言
- D.把代码编译成可执行文件
答案:B。代码嵌入的核心是把代码映射成稠密向量并保留语义,就像给菜记味道坐标——坐标不同,口味就不同。A 是字符串压缩,C 是翻译,D 是编译,都不产生可用于相似度计算的语义向量。
Q2. token 级固定词向量(如 Word2vec)无法解决什么问题?
- A.词表外的生僻 token 表示
- B.同一 token 在不同上下文中的歧义← 正确答案
- C.大规模语料的分布式训练难题
- D.向量维度选择的经验性问题
答案:B。固定词向量每个 token 只有一个向量,无法区分“数组的 sort”和“排序的 sort”在不同语境下的含义,就像“葱”只有一份档案。BPE 能缓解生僻词(A),分布式训练和维度选择都不是 Word2vec 特有的痛点。
Q3. 余弦相似度的正确含义是什么?
- A.两个向量模长的平方之差
- B.两个向量的夹角余弦值← 正确答案
- C.两个向量各维度之和的比值
- D.两个向量的欧氏距离倒数
答案:B。余弦相似度就是两个向量的夹角余弦值,只看方向不看长短,范围在 -1 到 1 之间。A 是模长相关概念,C 和 D 都不是标准相似度定义。
Q4. CodeBERT 的双模态输入指的是哪两种信息?
- A.源代码和对应的自然语言文档← 正确答案
- B.编译前源码和编译后字节码
- C.训练集和测试集的样本对
- D.高维向量和降维后的可视化
答案:A。CodeBERT 的输入是源代码 + 自然语言文档(注释、README)双模态,互相印证,相当于同时看“菜”和“菜谱”。B 是编译产物,C 是数据划分,D 是可视化,都不是预训练输入模态。
Q5. 掩码语言模型(MLM)预训练的目标是什么?
- A.预测句子下一个出现的 token
- B.根据上下文预测被掩盖的 token← 正确答案
- C.判断两个句子是否是相邻语句
- D.把整个序列还原为原始代码
答案:B。MLM 随机遮住一些 token,让模型根据剩余上下文预测被掩盖的 token,是“完形填空”。A 是自回归语言模型的目标,C 是 NSP,D 是去噪目标,都不属于 MLM 的定义。
Q6. UniXcoder 相比 CodeBERT 的核心扩展不包括哪一项?
- A.引入 AST 语法结构信息
- B.支持多语言统一预训练
- C.支持跨语言代码检索迁移
- D.放弃所有自监督训练目标← 正确答案
答案:D。UniXcoder 的扩展包括 AST 结构感知(A)、多语言统一建模(B)、跨语言迁移(C)。它不会放弃自监督目标,反而是用“掩码 + 对比 + 去噪”多任务目标把自监督做得更强。
Q7. 代码嵌入检索评测中 MRR 主要衡量什么?
- A.召回列表中首个相关结果的位置← 正确答案
- B.全部召回结果的精确率平均值
- C.阈值对精确率的影响程度
- D.向量维数对性能的敏感程度
答案:A。MRR(Mean Reciprocal Rank)衡量第一个相关结果出现在排名的哪个位置——排第 1 得 1 分,排第 3 得 1/3。精确率平均值对应 MAP(B),C 和 D 与 MRR 无关。
Q8. 下列哪些属于代码嵌入的典型应用?
- A.代码语义搜索与推荐← 正确答案
- B.代码克隆与抄袭检测← 正确答案
- C.基于嵌入特征的缺陷预测← 正确答案
- D.直接生成新的可执行程序
答案:ABC。搜索、克隆检测、缺陷预测都是嵌入向量的典型下游应用。D 是代码生成,属于生成任务而非嵌入表示的应用——就像味道地图能帮你找菜,但做菜是另一门手艺。
Q9. 判断嵌入质量时,以下哪些做法是合理的?
- A.在检索任务上用 MRR 评估← 正确答案
- B.用聚类可视化观察语义簇← 正确答案
- C.用归一化后的余弦相似度← 正确答案
- D.只比较向量模长的大小
答案:ABC。检索任务 MRR、聚类可视化、归一化后余弦相似度都是标准做法。只比向量模长没有语义意义——就像只比菜的重量不比味道。
Q10. 影响代码嵌入质量的因素有哪些?
- A.预训练语料的规模与领域← 正确答案
- B.预训练目标的设计方式← 正确答案
- C.是否引入代码结构信息← 正确答案
- D.推理时打印日志的多少
答案:ABC。语料规模与领域、预训练目标(掩码/对比/去噪)、是否引入 AST 结构信息,都直接影响嵌入质量。打印日志是工程排障手段,与嵌入的语义质量毫无关系。