完整教程:用Solidity编写智能合约、训练PyTorch情感分析模型、用Node.js构建预言机服务、前端用React接入MetaMask,最终部署到Sepolia测试网。
构建一个"加密货币 AI 推理市场"——一个小型 dApp,用户可以支付少量 Ether(或其他 ERC-20 代币)来获取机器学习模型的预测结果(对短文本的情绪分析)。
本教程涵盖了在本地启动项目、进行测试,以及最终部署到公共测试网(Sepolia)所需的一切内容。
# Node & npm
brew install node # macOS (or apt-get install nodejs npm)
# Hardhat (global optional)
npm i -g hardhat
# Python & venv
python3 -m venv .venv && source .venv/bin/activate
pip install torch torchvision torchaudio tqdm
# Git
brew install git
确保已在浏览器中安装 MetaMask 并将网络设置为 Sepolia。
crypto‑ai‑marketplace/
├─ contracts/ # Solidity contracts
│ └─ InferenceMarketplace.sol
├─ scripts/ # Hardhat deployment scripts
│ └─ deploy.js
├─ test/ # Hardhat tests (JS/TS)
│ └─ InferenceMarketplace.test.js
├─ ai/ # Python AI code
│ ├─ train.py
│ ├─ model.pt # exported TorchScript model (generated)
│ └─ requirements.txt
├─ server/ # Node.js oracle
│ ├─ index.js
│ ├─ package.json
│ └─ .env # secrets (Infura key, contract address, etc.)
├─ client/ # React front‑end
│ ├─ src/
│ │ ├─ App.jsx
│ │ └─ components/
│ └─ package.json
├─ hardhat.config.js
└─ README.md
我们逐步填充每个文件夹。
我们将使用 DistilBERT(一个 6600 万参数的 Transformer)进行二分类情绪分析的微调。该模型足够小,能在 CPU 上以小于 50 ms 的时间运行。
torch==2.2.0
transformers==4.38.0
datasets==2.16.0
tqdm==4.66.2
cd ai
pip install -r requirements.txt
import torch
from torch import nn
from transformers import AutoTokenizer, AutoModelForSequenceClassification
from datasets import load_dataset
from tqdm.auto import tqdm
MODEL_NAME = "distilbert-base-uncased"
DATASET = "imdb" # binary sentiment dataset
EPOCHS = 1 # for demo; increase for real accuracy
BATCH_SIZE = 16
DEVICE = torch.device("cuda" if torch.cuda.is_available() else "cpu")
def main():
# 1️⃣ Load dataset (train/validation split)
raw = load_dataset("imdb")
train_ds = raw["train"].shuffle(seed=42).select(range(2000)) # tiny subset
val_ds = raw["test"].shuffle(seed=42).select(range(500))
# 2️⃣ Tokenizer
tokenizer = AutoTokenizer.from_pretrained(MODEL_NAME)
def tokenize(batch):
return tokenizer(batch["text"], padding="max_length", truncation=True, max_length=128)
train_enc = train_ds.map(tokenize, batched=True, batch_size=BATCH_SIZE)
val_enc = val_ds.map(tokenize, batched=True, batch_size=BATCH_SIZE)
# 3️⃣ Convert to torch tensors
columns = ["input_ids", "attention_mask", "label"]
train_enc.set_format(type="torch", columns=columns)
val_enc.set_format(type="torch", columns=columns)
# 4️⃣ Model
model = AutoModelForSequenceClassification.from_pretrained(MODEL_NAME, num_labels=2)
model.to(DEVICE)
optimizer = torch.optim.AdamW(model.parameters(), lr=5e-5)
loss_fn = nn.CrossEntropyLoss()
# 5️⃣ Training loop
model.train()
for epoch in range(EPOCHS):
prog = tqdm(train_enc, total=len(train_enc), desc=f"Epoch {epoch+1}")
for batch in prog:
optimizer.zero_grad()
input_ids = batch["input_ids"].to(DEVICE)
attn_mask = batch["attention_mask"].to(DEVICE)
labels = batch["label"].to(DEVICE)
outputs = model(input_ids, attention_mask=attn_mask, labels=labels)
loss = outputs.loss
loss.backward()
optimizer.step()
prog.set_postfix(loss=loss.item())
# 6️⃣ Save as TorchScript (self‑contained, no Python needed)
model.eval()
example = {
"input_ids": torch.randint(0, 1000, (1, 128), dtype=torch.long).to(DEVICE),
"attention_mask": torch.ones((1, 128), dtype=torch.long).to(DEVICE)
}
scripted = torch.jit.trace(
lambda **kwargs: model(**kwargs).logits,
example_kwarg_inputs=example
)
scripted.save("model.pt")
print("✅ Model exported to model.pt")
if __name__ == "__main__":
main()
Node.js 预言机将使用 torchscript-node(一个轻量绑定)或通过 onnxruntime-node 加载模型。TorchScript 生成一个独立的二进制文件,无需 Python 即可加载,使推理服务器保持轻量。
cd ai
python train.py
你会得到 model.pt(约 250 MB)。将其提交到仓库(或者如果想要真正去中心化,可以存储在 IPFS 上)。本教程中我们将其保存在本地。
import torch
model = torch.jit.load("model.pt")
model.eval()
def predict(text: str):
from transformers import AutoTokenizer
tokenizer = AutoTokenizer.from_pretrained("distilbert-base-uncased")
enc = tokenizer(text, truncation=True, padding="max_length", max_length=128, return_tensors="pt")
with torch.no_grad():
logits = model(**enc)
prob = torch.softmax(logits, dim=-1).squeeze()
label = "POSITIVE" if prob[1] > prob[0] else "NEGATIVE"
return label, prob.tolist()
print(predict("I love crypto!"))
你应该能看到一个 POSITIVE 标签及其概率向量。
price – 用户每次推理支付的费用(以 wei 为单位)request mapping – 追踪哪个地址请求了哪段文本(哈希)event – InferenceRequested(address indexed user, bytes32 requestId, string text)function – requestInference(string calldata text) payable,检查价格并发出事件owner – 可以更新价格// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
/// @title Crypto‑AI Inference Marketplace
/// @notice Users pay a fixed price to request a prediction from an off‑chain AI model.
/// @dev The contract only stores request metadata and emits an event. The off‑chain oracle
/// listens for the event, runs inference, then calls `fulfillInference`.
contract InferenceMarketplace {
address public owner;
uint256 public price; // price per inference (in wei)
// requestId => result (0 = pending, 1 = positive, 2 = negative)
mapping(bytes32 => uint8) public results;
event InferenceRequested(address indexed user, bytes32 indexed requestId, string text);
event InferenceFulfilled(address indexed user, bytes32 indexed requestId, uint8 result);
modifier onlyOwner() {
require(msg.sender == owner, "Not owner");
_;
}
constructor(uint256 _price) {
owner = msg.sender;
price = _price;
}
/// @notice User sends `price` wei + text to get a prediction.
function requestInference(string calldata text) external payable returns (bytes32 requestId) {
require(msg.value == price, "Incorrect payment");
requestId = keccak256(abi.encodePacked(msg.sender, block.timestamp, text));
// Store a placeholder (0 = pending)
results[requestId] = 0;
emit InferenceRequested(msg.sender, requestId, text);
}
/// @notice Oracle calls this after computing the result.
/// @param requestId The id emitted in the request event.
/// @param result 1 = POSITIVE, 2 = NEGATIVE
function fulfillInference(bytes32 requestId, uint8 result) external onlyOwner {
require(results[requestId] == 0, "Already fulfilled");
require(result == 1 || result == 2, "Invalid result");
results[requestId] = result;
// Find the original user from the requestId (cannot be derived on‑chain,
// so we emit the user address again for front‑ends to catch)
emit InferenceFulfilled(msg.sender, requestId, result);
}
/// @notice Owner can withdraw accumulated fees.
function withdraw() external onlyOwner {
payable(owner).transfer(address(this).balance);
}
/// @notice Owner can change the price.
function setPrice(uint256 _price) external onlyOwner {
price = _price;
}
}
results[requestId] 来展示结果mkdir crypto-ai-marketplace && cd crypto-ai-marketplace
npm init -y
npm i -D hardhat @nomicfoundation/hardhat-toolbox ethers dotenv
npx hardhat # choose "Create a basic sample project"
用 InferenceMarketplace.sol 替换自动生成的示例合约。
require("@nomicfoundation/hardhat-toolbox");
require("dotenv").config();
module.exports = {
solidity: "0.8.24",
networks: {
sepolia: {
url: process.env.SEPOLIA_RPC_URL, // e.g., https://sepolia.infura.io/v3/<KEY>
accounts: [process.env.PRIVATE_KEY], // deployer account
},
localhost: {
url: "http://127.0.0.1:8545",
},
},
};
创建 .env 文件(切勿提交):
SEPOLIA_RPC_URL=https://sepolia.infura.io/v3/your-infura-id
PRIVATE_KEY=0xYOUR_PRIVATE_KEY # account with Sepolia test ETH
const { ethers } = require("hardhat");
async function main() {
const price = ethers.parseEther("0.001"); // 0.001 ETH per inference
const InferenceMarketplace = await ethers.getContractFactory("InferenceMarketplace");
const contract = await InferenceMarketplace.deploy(price);
await contract.waitForDeployment();
console.log("Contract deployed to:", contract.target);
console.log("Price (wei):", price.toString());
}
main()
.then(() => process.exit(0))
.catch((e) => {
console.error(e);
process.exit(1);
});
首先在 Hardhat 网络上本地运行:
npx hardhat node # in a separate terminal
npx hardhat run script/deploy.js --network localhost
你会看到合约地址(例如 0xAbc...)。保存它——预言机和前端都需要它。
npx hardhat run script/deploy.js --network sepolia
复制打印出的地址,稍后我们将其放入 server/.env 和 client/.env。
const { expect } = require("chai");
const { ethers } = require("hardhat");
describe("InferenceMarketplace", function () {
let contract, owner, user;
const price = ethers.parseEther("0.001");
beforeEach(async () => {
[owner, user] = await ethers.getSigners();
const Factory = await ethers.getContractFactory("InferenceMarketplace");
contract = await Factory.deploy(price);
await contract.waitForDeployment();
});
it("rejects wrong payment", async () => {
await expect(contract.connect(user).requestInference("test", { value: ethers.parseEther("0.0005") }))
.to.be.revertedWith("Incorrect payment");
});
it("accepts correct payment and emits event", async () => {
await expect(contract.connect(user).requestInference("hello", { value: price }))
.to.emit(contract, "InferenceRequested")
.withArgs(user.address, anyValue, "hello");
});
});
npx hardhat test
全部绿灯 → 你已经准备好进行预言机开发了。
预言机执行三项工作:
InferenceRequested 事件(通过 ethers.js + WebSocket provider)fulfillInference(使用 owner 密钥签名)mkdir server && cd server
npm init -y
npm i express ethers dotenv @xenova/transformers # we'll use @xenova/transformers for on‑node inference (no Python)
npm i -D nodemon
备选方案:使用 torchscript-node 绑定(需要原生编译)。为简单起见和跨平台兼容性,我们将使用可以加载模型 ONNX 版本的 JavaScript 推理库(稍后导出)。
在保存 model.pt 后,将以下内容添加到 ai/train.py:
torch.onnx.export(
model,
(example["input_ids"], example["attention_mask"]),
"model.onnx",
input_names=["input_ids", "attention_mask"],
output_names=["logits"],
dynamic_axes={"input_ids": {0: "batch_size"}, "attention_mask": {0: "batch_size"}}
)