智能合约辅助开发试验失败后的检查点

直接把 LLM 生成的代码放到智能合约自动化部署流水线里,差点让我们在测试网环境损失了几十个 ETH 的模拟资产。那是一次关于“大模型辅助生成 Solidity 合约并由 Agent 自动发起链上交易”的实验。实验跑到第 42 分钟,节点 RPC 告警日志像瀑布一样刷屏,后端服务占满 CPU 陷入无限重试死循环。

表面上看是 RPC 节点频繁返回 nonce too lowreplacement transaction underpriced,但拉出完整日志链条逐层盘查,根因远比想象的要离奇。

从一封告警邮件说起:不可靠的输出结构

实验的目标很直观:用大模型根据用户自然语言意图生成包含质押逻辑的合约代码,并通过内部 Agent 编排工具自动部署到测试链,最后触发初始化函数。

然而,大模型生成的结构化输出(JSON Schema)发生了严重的“字段飘移”。在某些边界 Prompt 下,模型输出的 gasLimit 字段从原本的纯数字变成了带有单位的字符串 "3000000 gas"

交易构造模块没有拦截这个非标字段。由于字符串在类型转换时变成了 NaN,RPC 客户端将缺省的 gasLimit 发送给节点,导致交易在链上执行中途抛出 Out of Gas。更致命的是,Agent 的异常处理重试机制缺乏状态锁,直接拿着旧 Nonce 重发交易,导致后续队列全部挂起。

证据链抓取:寻找破坏一致性的那个点

为了还原现场,我们调出了三套独立的日志系统:大模型 Prompt 响应日志、Agent 引擎追踪日志(Tracing)以及以太坊节点的 Trace API 日志。

在 JSON 响应日志中,发现了这段异常的 LLM 返包:

{
  "contract_name": "StakingPool",
  "estimated_gas": "3000000 gas",
  "constructor_args": ["0x7a250d5630B4cF539739dF2C5dAcb4c659F2488D", 1000]
}

而在 TypeScript 构造交易的逻辑里,代码采用了非常宽松的 Parse 方法:

// 导致隐患的旧代码逻辑
const rawGas = result.estimated_gas;
const txOptions = {
  gasLimit: parseInt(rawGas), // parseInt("3000000 gas") 变成了 3000000,但如果返回 "gas: 3000000" 就变成了 NaN!
};

当模型在某次迭代中把字符串格式微调为 "gas: 3000000" 时,parseInt 返回 NaN。Ethers.js 在序列化交易时自动将其抹去,交易带着节点默认极低的 Gas 限制被广播出去。

节点报错后,Agent 重试逻辑又暴露出第二个工程缺陷:Nonce 管理没有做本地锁与链上排空同步。Agent 重试模块误以为交易未被链上接纳,遂重复递交相同 Nonce 的交易,却又没有按照以太坊 EIP-1559 的规范提高至少 10% 的 maxPriorityFeePerGas,最终彻底把节点 RPC 的内存池(Mempool)卡死。

修复方案:结构化校验与 Nonce 编排

只靠 Prompt 约束大模型输出格式就像是在沙盒里搭积木,随时可能坍塌。我们必须在代码层面建立硬性防御。

首先,放弃脆弱的解析逻辑,引入基于 Zod 的强类型校验框架,同时在 LLM 接口层采用 JSON Mode 或 Strict Tool Calling 约束;其次,构建严格的 Solidity AST 语法分析器与本地 EVM 预模拟器;最后,重构 Nonce 管理器,引入 Redis 锁与状态机机制。

下面是重构后的关键防护中间件及链上交易发射引擎机制:

import { ethers } from "ethers";
import { z } from "zod";

// 1. 定义严格的大模型输出 Schema
export const LLMTransactionSchema = z.object({
  contractName: z.string().regex(/^[A-Z][a-zA-Z0-9_]*$/),
  gasLimit: z.number().int().positive().max(15_000_000),
  maxFeePerGasGwei: z.number().positive(),
  maxPriorityFeePerGasGwei: z.number().positive(),
  constructorArgs: z.array(z.union([z.string(), z.number(), z.boolean()])),
});

export type LLMTransactionPayload = z.infer<typeof LLMTransactionSchema>;

export class SafeWeb3Executor {
  private provider: ethers.JsonRpcProvider;
  private wallet: ethers.Wallet;
  private nonceLock: Map<string, number> = new Map();

  constructor(providerUrl: string, privateKey: string) {
    this.provider = new ethers.JsonRpcProvider(providerUrl);
    this.wallet = new ethers.Wallet(privateKey, this.provider);
  }

  /**
   * 校验 LLM 返包并执行预模拟
   */
  public async validateAndSimulate(
    rawModelOutput: unknown,
    bytecode: string,
    abi: ethers.InterfaceAbi
  ): Promise<LLMTransactionPayload> {
    // 强制严格校验
    const parseResult = LLMTransactionSchema.safeParse(rawModelOutput);
    if (!parseResult.success) {
      throw new Error(`LLM 输出契约破坏: ${parseResult.error.message}`);
    }

    const payload = parseResult.data;

    // 链上静态预估与 simulate (eth_call)
    const factory = new ethers.ContractFactory(abi, bytecode, this.wallet);
    const deployTx = await factory.getDeployTransaction(...payload.constructorArgs);

    try {
      // 在节点端进行模拟运行,防止无效交易上链消耗 Gas
      await this.provider.call({
        from: this.wallet.address,
        data: deployTx.data,
        value: 0,
      });
    } catch (err: any) {
      throw new Error(`链上预模拟失败,合约部署逻辑存在 Revert 隐患: ${err?.message || err}`);
    }

    return payload;
  }

  /**
   * 线程安全的 Nonce 发射锁
   */
  public async executeTransactionWithLock(
    targetTxData: ethers.TransactionRequest
  ): Promise<ethers.TransactionReceipt> {
    const address = await this.wallet.getAddress();
    
    // 获取当前最新挂起 Nonce
    let currentNonce = await this.provider.getTransactionCount(address, "pending");
    
    const lastUsedNonce = this.nonceLock.get(address) ?? -1;
    if (currentNonce <= lastUsedNonce) {
      currentNonce = lastUsedNonce + 1;
    }
    
    this.nonceLock.set(address, currentNonce);

    try {
      const tx = await this.wallet.sendTransaction({
        ...targetTxData,
        nonce: currentNonce,
      });

      const receipt = await tx.wait(1);
      if (!receipt || receipt.status !== 1) {
        throw new Error(`交易执行失败,Receipt Status: ${receipt?.status}`);
      }
      return receipt;
    } catch (error: any) {
      // 发生异常时清理 Nonce 本地锁缓存,避免阻塞后续交易
      this.nonceLock.delete(address);
      throw new Error(`链上提交异常失败: ${error?.message || error}`);
    }
  }
}

生产验证与压测结果

在把改进后的 SafeWeb3Executor 部署至测试环境并启动 100 轮并发压力测试后,我们收集了系统收敛前后的关键指标。

在旧架构下,一旦大模型输出异常格式,错误处理率低至 12%,大部分失败请求沉淀为锁死在内存池中的无效 Nonce;而在新架构介入强类型 Schema 与 eth_call 模拟后,无效交易上链拦截率达到了 100%。即使 LLM 吐出了不符合规范的数据,系统也在 2 毫秒内于本地防护层直接阻断,根本不会触发任何无效的链上广播。

具体的实验数据对比记录在下表中:

测试评估维度 原始解析与非阻塞架构 重构后 SafeWeb3Executor 方案
LLM 异常结构拦截率 0%(靠简单 Parse 碰运气) 100%(Zod Schema 强拦截)
无效 Gas 损耗 平均单次故障浪费 0.08 ETH 0 ETH(链上预模拟阻断)
Nonce 队列挂起概率 38.5%(高并发下极为频繁) 0%(内存排号锁 + 异常置换机制)
平均端到端延迟 12400ms(含大量超时重试) 850ms(快速失败与正确上链)

这次失败的实验给我们的工程教训足够深刻:在 AI + Web3 的结合点上,大模型只能做“提议者”(Proposer),绝对不能做“执行者”(Executor)。

必须在二者之间架起一座由严格 Schema 校验、静态 AST 语法树分析、沙箱模拟运行以及原子性 Nonce 状态锁构成的工程隔离墙。只有这样,链上资产的安全性与自动化流水线的稳定性才不至于沦为概率学博弈。

把失败现场还原

这篇讨论的是智能合约与网页应用里的“智能合约辅助开发试验失败后的检查点”。判断不能只靠某一次顺利的结果,需要把合约调用、钱包签名、接口响应、测试网和审计记录放回同一段执行过程里看。先固定失败请求的输入、版本和时间窗口,再把日志按一次执行链串起来。只看最后一条报错常会误判;同一症状可能来自配置漂移、依赖变化或并发触发。记录时把已经排除的条件写出来,下一次复现不必从猜测开始。

实际处理时,我会先选一个普通请求和一个边界请求,分别记下开始时间、关键输入与最终结果。若两者差异很大,就继续向下拆分,而不是马上把问题归因给某个工具。这里的目标不是把记录做得漂亮,而是让后来接手的人能够复走当时的路径。

交付前留下什么

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐