做隐私计算的朋友,对 OpenFHE 这个名字应该不陌生。它是目前开源社区里维护非常活跃的全同态加密库,覆盖了 BFV、BGV、CKKS、FHEW/TFHE 等多套主流方案。我最早接触它是在一个多方数据协同的试验项目里,要在不可信环境下对加密后的样本做统计运算,当时最头疼的事就是:每个计算任务都得重新编译一遍程序,业务方改一个公式,我就得跟着改代码。后来我把 OpenFHE 接到了一个支持运行时输入表达式的计算前端上,让用户直接输入计算指令,程序负责把指令翻译成同态操作,这才算把流程跑顺了。这篇就来详细讲讲这个“可主动输入计算代码”是怎么设计和落地的,包括参数怎么设、代码怎么写、哪些坑我替你先踩过了。适合正在评估同态加密落地,或者已经在用 OpenFHE 但想做更灵活计算编排的开发者。
1. 先搞清楚 OpenFHE 能干什么,别急着写代码
同态加密的本质,是允许在密文上直接做计算,计算完解密出来的结果和直接在明文上做计算一致。OpenFHE 把这一堆底层密码学操作封装成了相对友好的 C++ 和 Python API,但真正用起来,前置决策比写代码复杂得多。
1.1 为什么是“可主动输入计算代码”
传统用法里,计算逻辑是硬编码在程序里的:程序写死了先加后乘,密文进来只能按这个顺序跑。这在数据场景很受限,因为业务方往往希望灵活调整指标口径,比如今天算均值,明天要换成分位数组,后天又要做加权求和。如果每次都要改 C++ 代码、重新编译部署,同态加密的优势就大打折扣。
“可主动输入计算代码”要解决的,就是把这个硬编码的计算逻辑从编译期挪到运行期。用户在终端或者接口里输入一串表达式,例如mul(add(enc1, enc2), enc3),程序解析这串字符串,把它映射到 OpenFHE 的EvalAdd、EvalMult等操作上,逐个执行后返回结果。在这套设计里,计算请求变成了数据,可以随时下发,业务方不用再等开发介入。
这个设计同时引入了两件事:一是表达式解析,也就是把字符串变成可执行的操作树;二是同态执行器的封装,把 OpenFHE 的操作封装成操作树节点对应的函数。两者都不复杂,但放在一起,体验和硬编码完全不同。
1.2 三种主流方案,到底该用哪个
OpenFHE 支持的好几个方案不是随便选的,每种都有明确的适用边界。
BFV 和 BGV 都走整数运算路线,加密、解密和运算过程处理的是模整数。区别主要在实现机制:BFV 基于多项式商环上的缩放,BGV 基于有限域上的级联模数切换。如果你要算的是计数、金额、ID 集合这类必须精确的整数,优先看这两个方案。BGV 在深度较大的电路中效率通常更高一点,BFV 在层数浅、单次乘法多的场景更直观。
CKKS 走的是近似计算路线,支持浮点向量打包计算,允许结果带一点可控误差。大量机器学习推理、统计分析以及需要处理小数或者大向量运算的场景,基本都用 CKKS。代价是误差管理和参数调整比整数方案麻烦,同一个乘法深度下,CKKS 需要额外关注 scale factor 和精度损失。
我做这个可输入计算代码的功能时,选的是 CKKS。原因是业务方要的指标里混合了均值、方差、相关系数这些浮点运算,BP 或者 BGV 得通过定点化放大,维护成本比较高。如果你的业务全部是精确整数,那 BFV/BGV 更合适,代码主体逻辑可以复用,只有编码和解码部分不同。
1.3 从“硬编码计算”到“运行时计算表达式”
同态计算的常见写法是先加密,再调用EvalAdd、EvalMult,最后解密。在这个链路里加入表达式解析,本质上是在计算层上面加了一个翻译层。
我建议不要把解析逻辑和 OpenFHE 的调用混在一起写,而是分成三层:表达式层、执行层、密码学层。表达式层负责字符串的语法解析,输出一棵操作树;执行层拿到操作树,递归遍历,每遇到一个节点就调用密码学层对应的封装函数;密码学层里才是 OpenFHE 的EvalAdd、EvalMult这些直接调用。
这样分层之后,改表达式的语法规则不会影响密码学代码,换底层方案库时也不需要动执行层。我第一次就干过混写的蠢事,表达式解析和密码学调用挤在一起,后来为了支持更复杂的嵌套表达式,几乎重写了一遍。
2. 环境准备与 CryptoContext 参数规划
OpenFHE 的入门 Demo 看着简单,但参数一旦设错,后面全是莫名其妙的结果。这一节先说环境,再重点讲CryptoContext,这是整个参数体系的核心对象。
2.1 两种安装方式
OpenFHE 官方推荐两种安装路径。第一种是用 vcpkg,如果你已经在用 vcpkg 管理依赖,直接执行vcpkg install openfhe即可,会自动拉取对应平台的预编译库。第二种是从源码编译,适合需要定制编译选项或者调试底层实现的场景:
git clone https://github.com/openfheorg/openfhe-development.git cd openfhe-development cmake -S . -B build -DBUILD_SHARED_LIBS=ON -DCMAKE_BUILD_TYPE=Release cmake --build build -- -j4 cmake --install build编译时用 Release 模式非常重要,Debug 模式下的多项式运算慢到怀疑人生。-DBUILD_SHARED_LIBS=ON建议打开,动态库能显著缩小可执行文件体积,迭代调试时也方便。如果后续要做 Python 调用,还需要额外绑定 Python 的编译开关,但 C++ 原型阶段不用管。
2.2 CryptoContext 是核心
所有方案参数都通过CCParams组装起来,然后传给GenCryptoContext生成一个真正的密码上下文对象。这个对象负责密钥生成、加密解密、同态运算的所有底层调度。以 CKKS 为例,我常用的初始化代码如下:
using namespace lbcrypto; CCParams<CryptoContextCKKSRNS> parameters; parameters.SetMultiplicativeDepth(3); parameters.SetScalingModSize(50); parameters.SetBatchSize(8192); parameters.SetSecurityLevel(HEStd_128_classic); CryptoContext<DCRTPoly> cc = GenCryptoContext(parameters);SetMultiplicativeDepth决定允许的乘法层数,每做一次EvalMult就会消耗一个 level,level 耗尽后噪声会压过信号,解密结果完全错误。SetScalingModSize控制 CKKS 的浮点精度,一般取 40 到 60,越大精度越高但性能越差。SetBatchSize决定明文向量一次能打包多少个元素,8192 意味着一次能加密一个长度为 8192 的浮点向量,这对批量统计非常有用。
SetSecurityLevel直接决定底层参数规模,HEStd_128_classic是常见的选择,如果只是原型验证可以用HEStd_NotSet来加速,但正式环境不要省安全性。
2.3 密钥生成与 Enable 开关
执行GenCryptoContext之后,还需要显式开启功能模块。OpenFHE 的设计是默认只开最基础的公钥加密,其他高级功能都要手动 Enable,这既是为了控制密钥体积,也是为了避免误用:
cc->Enable(PKE); cc->Enable(KEYSWITCH); cc->Enable(LEVELEDSHE);PKE是公钥加密,不开就没法做Encrypt。KEYSWITCH是密钥切换,EvalMult 和 EvalRotate 都依赖它。LEVELEDSHE是层级同态能力,包括乘法和旋转操作。如果你需要自举,还要追加ADVANCEDSHE或FHE。
然后生成密钥对:
auto keyPair = cc->KeyGen(); cc->EvalMultKeyGen(keyPair.secretKey); cc->EvalRotateKeyGen(keyPair.secretKey, {1, 2, -1, -2});第一行生成密钥对,第二行生成乘法重线性化密钥,第三行生成旋转密钥。EvalRotateKeyGen里面传的集合代表旋转步长,例如1表示把向量向左旋转一个位置。如果你要支持任意步长的旋转,需要预先列出所有可能用到的步长,运行期再生成就会很不方便。为了动态传入计算表达式,我通常会生成一批默认旋转密钥,代价是公钥和密钥体积变大,但换来执行期的灵活性。
3. 实操主线:做一个能主动输入表达式的同态计算器
这一节是全文核心。我会带你做一个交互式同态计算器,用户输入类似add(enc1, enc2)的字符串,程序解析后在密文上执行对应操作。这里面有三个关键环节:密文存储、表达式解析、执行调度。
3.1 密文登记表:让用户能从内存中找到密文
用户输入表达式时,引用的不是密文对象本身,而是一个标识符,比如enc1、enc2。所以程序需要维护一个全局的密文登记表,把字符串标识符映射到实际的密文对象:
std::unordered_map<std::string, Ciphertext<DCRTPoly>> ciphertextTable; uint32_t ciphertextCounter = 0; std::string storeCiphertext(Ciphertext<DCRTPoly> ct) { std::string id = "enc" + std::to_string(ciphertextCounter++); ciphertextTable[id] = ct; return id; }加密后的数据进入登记表,后续运算结果也进入登记表。这相当于提供了一个密文工作区,用户的每一个计算指令都从工作区取数,算完再放回工作区。实际项目中,这个工作区可能需要对接外部存储或者数据库,把密文序列化后持久化,我这里为了聚焦逻辑只做了内存版。
3.2 表达式解析:把字符串转换成同态操作
表达式解析的复杂度取决于你想支持多复杂的语法。我推荐先做一个递归下降解析器,支持两种节点:数字引用节点和二元运算节点。比如add(enc1, mul(enc2, enc3))会被解析成下面的操作树:
add ├── enc1 └── mul ├── enc2 └── enc3不画流程图的习惯就先跳过图表,具体解析可以用一个递归函数描述。
struct ExprNode { std::string op; // "add" / "mul" / "enc" std::string operandId; // 当 op == "enc" 时有效 std::vector<ExprNode> children; }; ExprNode parseExpression(const std::string& expr, size_t& pos) { if (expr[pos] == 'e') { // 数字引用,如 enc1 size_t end = pos; while (end < expr.size() && expr[end] != ',' && expr[end] != ')') end++; std::string token = expr.substr(pos, end - pos); pos = end; return ExprNode{"enc", token, {}}; } // 读取 op 名称 size_t paren = expr.find('(', pos); std::string op = expr.substr(pos, paren - pos); pos = paren + 1; ExprNode node{op, "", {}}; while (true) { auto child = parseExpression(expr, pos); node.children.push_back(child); if (expr[pos] == ',') { pos++; continue; } if (expr[pos] == ')') { pos++; break; } } return node; }解析器的核心思路是把表达式拆成“操作符 + 子表达式”的递归结构。同态运算天然可以表示成这种树结构,因为无论EvalAdd还是EvalMult,都是接收两个输入返回一个输出,组合起来形成一个树形电路。
3.3 同态执行与结果解密
拿到操作树之后,执行器递归遍历,叶子节点从密文登记表取出密文,内部节点调用 OpenFHE 的同态操作:
Ciphertext<DCRTPoly> evalNode(const ExprNode& node, CryptoContext<DCRTPoly>& cc) { if (node.op == "enc") { return ciphertextTable[node.operandId]; } if (node.op == "add") { auto left = evalNode(node.children[0], cc); auto right = evalNode(node.children[1], cc); return cc->EvalAdd(left, right); } if (node.op == "mul") { auto left = evalNode(node.children[0], cc); auto right = evalNode(node.children[1], cc); return cc->EvalMult(left, right); } throw std::runtime_error("unknown operation: " + node.op); }这个执行器最简单的形态只有add和mul,但扩展起来很容易。加一个rotate节点,就对应cc->EvalRotate;加一个sub,就对应cc->EvalSub;加一个sum,就对应cc->EvalSum。每个新操作要做两件事:在解析器里识别新的操作符,在执行器里映射到 OpenFHE 的调用。
主循环非常简单:
std::string line; while (std::getline(std::cin, line)) { size_t pos = 0; auto expr = parseExpression(line, pos); auto result = evalNode(expr, cc); std::string id = storeCiphertext(result); std::cout << "computed, stored as " << id << std::endl; }用户输入add(enc0, enc1),程序加密完成后返回enc2,再输入mul(enc2, enc3)继续在密文上累乘。整个过程用户不需要接触任何密钥,也不接触明文数据。
解密模块单独提供,通常由持有私钥的一方本地执行:
void decryptAndPrint(const std::string& id, CryptoContext<DCRTPoly>& cc, KeyPair<DCRTPoly>& keyPair) { Plaintext ptxt; cc->Decrypt(keyPair.secretKey, ciphertextTable[id], &ptxt); ptxt->SetLength(batchSize); std::vector<double> result = ptxt->GetRealPackedValue(); for (double v : result) std::cout << v << " "; std::cout << std::endl; }我实际项目里就是这样处理的:加密数据和计算在服务端完成,解密只在受信客户端执行。这个模式下,用户主动输入的计算指令直接驱动密文运算,业务的表达空间大了非常多。
4. 运算深度、噪音和 Scale:参数为什么这么设
如果你把上面的代码原样跑起来,大概率会遇到两个问题:一是解密结果不对,二是跑着跑着密文膨胀、速度急剧下降。这些问题的根源都在参数层面。
4.1 乘法深度与 Level
CKKS 的精度受噪声支配,而噪声增长最快的操作是乘法。每做一次EvalMult,密文的噪声预算就会降到下一个 level,乘法深度设置得越大,系统预留的模数链就越长,密文尺寸和运算耗时也随之增大。
我用一个简单规则来估算需求:先画出业务最复杂的表达式树,数一下任一子树路径上的最大乘法次数。例如mul(add(a, b), mul(c, d)),最长乘法链是两次,那么SetMultiplicativeDepth(3)至少留出 3 个 level 才安全。这里不是简单地等于乘法次数加一就行,还要考虑 CKKS 每次乘法后重线性化和 rescale 的开销,通常建议比估算值再多留 1 到 2 层。
我自己第一次直接设成 2,跑一个三级乘法的表达式时,解密出来全部是乱码,检查了很久才发现是 level 不够。同态加密的调试就是这么反直觉——结果错得很"完美",完全看不出哪一步坏了。
4.2 Encoder 与 Scale Factor
CKKS 的编码器把浮点向量打包到多项式上,编码时会乘一个 scale factor。这个 scale factor 就是SetScalingModSize指定的位数。每次乘法之后,密文做 rescale 把 scale 降回来,保证数值范围稳定。
如果 scale factor 设得太小,小数部分会被截断,累积几次乘法后误差会大到失真。如果设得太大,模数链长度不变的前提下,能容纳的 level 数变少,效果等同于降低了乘法深度。实际中我常用 40 到 50 之间的值做原型,生产环境再根据数值范围测试调到合适的位宽。
有一个细节值得注意:批处理时,向量里的每个元素共享同一个 scale factor,如果业务数据的量级差异特别大,比如既有 0.0001 也有 10000,编码后小数的精度会被大数"挤压"。这种场景最好先做归一化,或者按量级拆分到不同的密文批次里。
4.3 旋转、重线性化和自动扩展
EvalRotate是实现向量移位的关键操作,比如把一批样本整体向右移动一个位置,以计算相邻元素的差值。每次旋转都需要对应的旋转密钥,这个密钥在EvalRotateKeyGen时指定步长集合。
旋转的表面开销是密钥体积变大,但真正的坑在表达式层。用户输入rotate(enc0, 2)时,执行器要决定步长 2 是否在预生成的密钥集合里,不在就得当场生成或者拒绝。为了支持任意旋转步长,我后来把旋转密钥集合直接生成到 ±16,用密钥体积换灵活性,在小规模验证项目中完全可以接受。
重线性化是乘法后自动做的,OpenFHE 的EvalMult默认会做重线性化,把密文的维度从三层压缩回两层。如果你在同一份代码里反复使用同一个密文作为乘法输入,重线性化的开销会累加。实测下来,乘法密集的表达式里,重线性化能占到总运算时间的四成左右。没有捷径,但可以先在普通明文表达式里验证逻辑正确性,再切到同态执行,避免在错误逻辑上浪费大量算力。
5. 踩坑记录:从编译到解密出错的问题排查
最后这部分是我在实际落地过程中被反复折磨过的问题,把它整理成速查表,能帮你省下几天排查时间。
5.1 解密出来是一堆垃圾
这是遇到最多的情况,原因按概率排列如下:
第一,乘法深度不够。表达式路径上的乘法次数超过SetMultiplicativeDepth的设定,噪声预算耗尽。排查方法是把表达式拆成一半来测,逐步确认是否与乘法层数相关。
第二,scale factor 太小。CKKS 下如果数值本身很小,比如 0.001,而 scale 只有 30 bit,几次乘法后精度就崩了。试着把SetScalingModSize调到 50,并且用同样数据重新跑一遍看结果。
第三,编码和解码批次长度不一致。SetLength设得不对,取出来的向量尾部是垃圾值。我习惯在加密前记录batchSize,解密后强制SetLength(batchSize),避免默认长度带来的内存尾差。
5.2 EvalMultKey 缺失导致崩溃
运行期调用EvalMult时偶尔会直接抛异常,提示没有对应的乘法密钥。这种情况常见于代码里分出了多个CryptoContext实例,或者密钥生成和计算发生在不同的线程里。OpenFHE 的各级密钥都是绑定在特定CryptoContext上的,换一个上下文,密钥就失效了。
排查时先确认EvalMultKeyGen和EvalRotateKeyGen是否在同一个cc对象上调用,再检查是否被序列化到磁盘后恢复了错误的上下文。如果用多线程,最简单的方案是计算线程只读访问上下文对象,避免并行调用密钥生成。
5.3 性能瓶颈与批量处理
同态计算慢是常态,但不能慢到不可用。我试验项目里的数据量是百万级样本,单条加密跑太不现实,必须利用 CKKS 的批处理能力。把一万条记录打包成一个 8192 长度的向量,一次加密、一次计算,效率提升非常明显。
如果计算表达式中同时涉及向量加法和逐元素乘法,尽量先做加法再做乘法。加法不消耗 level,乘法消耗,调整操作顺序能在同一声明深度下支持更多层运算。这属于纯表达式层的优化,不需要动密码学底层,收益却很直观。
另外值得提的是,表达式解析器的性能在这里根本不是瓶颈,瓶颈永远是 OpenFHE 的高斯采样和多项式乘法。别花大量时间优化解析器,正则表达式或者简单的递归下降足够用了。我在早期犯过这个错误,把解析器优化了半天,最后 profiler 一看,解析只占 0.1% 的耗时。
最后的一点实际经验
我做完这套可主动输入计算代码的功能后,最大的感受是:OpenFHE 本身的 API 并不难,难的是把参数规划和业务表达式设计放在同一条线上考虑。每次业务方新增指标,我不再需要改代码重编译,而是只要算清楚新的表达式需要几层乘法、需不需要旋转,然后调整加密参数就能跑起来。这套模式对数据协同、隐私计算平台这类场景特别合适,等于把同态计算能力做成了一个可交互的服务。顺带提个建议,正式上线前,最好在表达式解析层加一个白名单机制,只允许解析器接受预设的安全操作符,避免恶意构造的超深电路把服务端算力耗尽。这一点对于对外开放的计算服务尤其重要,虽然不涉及密码学安全问题,但工程上必须防住。