MoreRSS

site iconluozhiyun修改

93年,非科班程序员,喜欢健身、读书、编程。
请复制 RSS 到你的阅读器,或快速订阅到 :

Inoreader Feedly Follow Feedbin Local Reader

luozhiyun的 RSS 预览

LangGraph 是如何让LLM产生确定性输出的?

2026-01-10 20:46:07

像经常用 LLM 的同学都知道现在最头疼的问题就是幻觉问题,在金融或精密计算领域,不确定性意味着风险。 如果 Agent 负责分析 NVDA 或 TSLA 的财报,开发者希望它在处理相同数据时,逻辑推导链条是严密的,而不是在不同时间给出自相矛盾的结论。或是需要 LLM 输出 JSON 来触发一个 API,我们不会希望 LLM 在 JSON 里多加了一个逗号或改变了字段名。

最后我还尝试用 LangGraph 的理念自己写了一个 smallest-LangGraph

LangGraph 可以做什么?

传统的 LangChain 核心逻辑是 DAG(有向无环图)。我们可以轻松定义 A -> B -> C 的步骤,但如果你想让 AI 在 B 步骤发现结果不满意,自动跳回 A 重新执行,LangChain 的普通 Chain 很难优雅地实现。并且在复杂的长对话或多步骤任务中,维护一个全局的、可持久化的“状态快照”非常困难。

所以为了解决这些问题,LangGraph 就诞生了。LangGraph 的主要有这些核心优势:

  1. 支持“循环(Cycles)”与“迭代”

    思考 -> 2. 行动 -> 3. 观察结果 -> 4. 如果不满意,回到第1步。 LangGraph 允许你定义这种闭环逻辑,这在长任务、自我修正代码、多轮调研场景下是刚需。

  2. 状态管理

    LangGraph 引入了 State 的概念,所有节点共享同一个 TypedDict,你可以精确定义哪些数据是追加的(operator.add),哪些是覆盖的。并且它可以自动保存每一步的状态。即使程序崩溃或需要人工审核,你也可以从特定的“存档点”恢复,而不需要从头运行。

  3. 人机协作

    LangGraph 允许你将流程设计为“在某处强制停下”,等待人类信号后再继续。这在 LangChain 的线性模型中极难实现,但在 LangGraph 的状态机模型中只是一个节点属性。

  4. 高度可控

    “如果工具返回报错,必须走 A 路径。” 这种确定性对于生产环境的后端服务至关重要。不能让模型乱输出,在生产环境上严格把控输出结果是很重要的。

LangGraph 结构

由于 LangGraph 的核心思想是将 Agent 的工作流建模为一张有向图(Directed Graph)。所以 LangGraph 有如下几个结构组成

  • 全局状态(State)

    这个状态通常被定义为一个 Python 的 TypedDict,它可以包含任何你需要追踪的信息,如对话历史、中间结果、迭代次数等,所有的节点都能读取和更新这个中心状态。

  • 节点(Nodes)

    每个节点都是一个接收当前状态作为输入、并返回一个更新后的状态作为输出的 Python 函数。

  • 边(Edges)

    边负责连接节点,定义工作流的方向。最简单的边是常规边,它指定了一个节点的输出总是流向另一个固定的节点。而 LangGraph 最强大的功能在于条件边(Conditional Edges)。它通过一个函数来判断当前的状态,然后动态地决定下一步应该跳转到哪个节点。

基于上面的概念,我们来做一个例子,假设我们要开发一个 Agent:它先翻译一段话,然后自己检查是否有语法错误,如果有,就打回重新翻译;如果没有,就结束。

首先,我们先定义状态 (State):

from typing import TypedDict, List

class AgentState(TypedDict):
    # 原始文本
    input_text: str
    # 翻译后的文本
    translated_text: str
    # 反思反馈
    feedback: str
    # 循环次数(防止死循环)
    iterations: int

定义节点逻辑 (Nodes):

def translator_node(state: AgentState):
    print("--- 正在翻译 ---")
    # 这里通常会调用 LLM
    new_text = f"Translated: {state['input_text']}" 
    return {"translated_text": new_text, "iterations": state.get("iterations", 0) + 1}

def critic_node(state: AgentState):
    print("--- 正在自检 ---")
    # 模拟检查逻辑,如果包含 'bad' 字符就认为不合格
    if "bad" in state['translated_text']:
        return {"feedback": "发现不当词汇,请重试"}
    return {"feedback": "OK"}

定义路由逻辑 (Conditional Edges):

def should_continue(state: AgentState):
    if state["feedback"] == "OK" or state["iterations"] > 3:
        return "end"
    else:
        return "rephrase"

构建图 (Graph Construction):

from langgraph.graph import StateGraph, END

# 1. 初始化图
workflow = StateGraph(AgentState)

# 2. 添加节点
workflow.add_node("translator", translator_node)
workflow.add_node("critic", critic_node)

# 3. 设置入口点
workflow.set_entry_point("translator")

# 4. 连接节点
workflow.add_edge("translator", "critic")

# 5. 添加条件边 (根据 critic 的反馈决定去向)
workflow.add_conditional_edges(
    "critic",
    should_continue,
    {
        "rephrase": "translator", # 如果不 OK,回到翻译节点
        "end": END                # 如果 OK,结束
    }
)

# 6. 编译成可执行应用
app = workflow.compile()

通过上面这种编排方式,可以让 LLM 概率性输出产生确定性的输出,通过各种限制节点,很好的控制了 LLM 的访问的节点。

下面我给出完整的例子,大家可以用这个例子去尝试一下:

from typing import TypedDict, List
from langchain_openai import ChatOpenAI
from langgraph.graph import StateGraph, END
from langchain_core.messages import SystemMessage, HumanMessage

llm = ChatOpenAI(
    temperature=0.6,
    model="glm-4.6V",
    openai_api_key="",
    openai_api_base="https://open.bigmodel.cn/api/paas/v4/"
)

class AgentState(TypedDict):
    # 原始文本
    input_text: str
    # 翻译后的文本
    translated_text: str
    # 反思反馈
    feedback: str
    # 循环次数(防止死循环)
    iterations: int

def translator_node(state: AgentState):
    """翻译节点:负责将中文翻译成英文"""
    print(f"\n--- [节点:翻译器] 第 {state.get('iterations', 0) + 1} 次尝试 ---")

    iters = state.get("iterations", 0)
    feedback = state.get("feedback", "无")

    # 构建提示词:如果是重试,带上反馈建议
    system_prompt = "你是一个专业的翻译官。请将用户的中文翻译成地道、优雅的英文。"
    if iters > 0:
        system_prompt += f" 注意:这是第二次尝试,请参考之前的反馈进行改进:{feedback}"

    response = llm.invoke([
        SystemMessage(content=system_prompt),
        HumanMessage(content=state["input_text"])
    ])

    return {
        "translated_text": response.content,
        "iterations": iters + 1
    }

def critic_node(state: AgentState):
    """评审节点:检查翻译质量"""
    print("--- [节点:评审员] 正在检查翻译质量... ---")

    system_prompt = (
        "你是一个严苛的英文编辑。请评价以下翻译是否准确、地道。"
        "如果翻译得很好,请只回复关键词:【PASS】。"
        "如果翻译有改进空间,请直接指出问题并给出改进建议。"
    )

    user_content = f"原文:{state['input_text']}\n译文:{state['translated_text']}"

    response = llm.invoke([
        SystemMessage(content=system_prompt),
        HumanMessage(content=user_content)
    ])

    return {"feedback": response.content}

# 4. 定义路由逻辑
def should_continue(state: AgentState):
    """判断是继续修改还是直接结束"""
    if "【PASS】" in state["feedback"] or state["iterations"] >= 3:
        if state["iterations"] >= 3:
            print("!!! 达到最大尝试次数,停止优化。")
        return "end"
    else:
        print(f">>> 反馈建议:{state['feedback']}")
        return "rephrase"

# 1. 初始化图
workflow = StateGraph(AgentState)

# 2. 添加节点
workflow.add_node("translator", translator_node)
workflow.add_node("critic", critic_node)

# 3. 设置入口点
workflow.set_entry_point("translator")

# 4. 连接节点
workflow.add_edge("translator", "critic")

# 5. 添加条件边 (根据 critic 的反馈决定去向)
workflow.add_conditional_edges(
    "critic",
    should_continue,
    {
        "rephrase": "translator", # 如果不 OK,回到翻译节点
        "end": END                # 如果 OK,结束
    }
)

# 6. 编译成可执行应用
app = workflow.compile()

# 7. 运行时交互
if __name__ == "__main__":
    print("=== LangGraph 智能翻译 Agent (输入 'exit' 退出) ===")
    while True:
        user_input = input("\n请输入想要翻译的中文内容: ")
        if user_input.lower() == 'exit':
            break

        # 初始状态
        initial_state = {
            "input_text": user_input,
            "iterations": 0
        }

        # 运行图并获取最终状态
        final_state = app.invoke(initial_state)

        print("\n" + "=" * 30)
        print(f"最终翻译结果:\n{final_state['translated_text']}")
        print("=" * 30)

LangGraph 是如何管理状态的?

State Reducer 自动合并 state

Reducer 在 LangGraph 中就是一种更新状态的处理逻辑,如果没有指定默认行为是 用新值覆盖旧值。想要指定 Reducer 只需要通过 typing.Annotated 字段绑定一个 Reducer 函数即可。

比如使用 operator.add 定义这是一个“追加型”字段:

from typing import Annotated, TypedDict
from langgraph.graph import StateGraph, END
import operator

# 定义状态结构 (类似 Go 的 Struct)
class AgentState(TypedDict):
    # 使用 Annotated 和 operator.add 定义这是一个“追加型”字段
    # 每次节点返回消息,都会 append 到这个列表,而不是覆盖它
    messages: Annotated[list[str], operator.add]

    # 普通字段,默认行为是 Overwrite (覆盖)
    # 适合存储状态机当前的步骤或分析结论
    current_status: str

    # 计数器,也可以使用 operator.add 实现增量累加
    retry_count: Annotated[int, operator.add]

Checkpointer + Thread 持久化状态

在 LangGraph 中,Checkpointer 是一个持久化层接口,这意味着历史的对话记录,可以被自动持久化到数据库(如 SQLite 或其他外部数据库)中。这使得即使应用程序重启或用户断开连接,对话历史也能被保存和恢复,从而实现“真正的多轮记忆”。

LangGraph 提供了多种 Checkpointer 以便应对不同的使用场景:

  • MemorySaver 保存在内存,适用开发调试、单元测试;

  • SqliteSaver 保存在本地的.db文件,轻量级应用、边缘计算适合单机部署;

  • PostgresSaver 保存在 PostgreSQL,适合用在生产环境、多实例部署;

  • RedisSaver 适合处理高频、短时会话;

LangGraph 通过 thread_id 会话的唯一标识,结合 Checkpointer 就可以实现状态的隔离:

首先指定一个 指定一个 thread_id,所有相关的状态都会被保存到这个线程中。

config = {"configurable": {"thread_id": "conversation_1"}}
graph.invoke(input_data, config)

编译的时候传入 Checkpointer 即可。

# 创建 checkpointer
checkpointer = InMemorySaver()
# 编译图时传入 checkpointer
graph = builder.compile(checkpointer=checkpointer)

完整示例:

from langgraph.checkpoint.memory import InMemorySaver
from langchain_openai import ChatOpenAI 
from langgraph.graph import StateGraph, START, END, MessagesState 

llm = ChatOpenAI(
    temperature=0.6,
    model="glm-4.6v",
    openai_api_key="",
    openai_api_base="https://open.bigmodel.cn/api/paas/v4/"
)

# 定义节点函数
def call_model(state: MessagesState):
    response = llm.invoke(state["messages"])
    return {"messages": response}

# 构建图
builder = StateGraph(MessagesState)
builder.add_node("agent", call_model)
builder.add_edge(START, "agent")

# 创建 checkpointer
checkpointer = InMemorySaver()
# 编译图时传入 checkpointer
graph = builder.compile(checkpointer=checkpointer)

# 第一次对话
config = {"configurable": {"thread_id": "user_123"}}
response1 = graph.invoke(
    {"messages": [{"role": "user", "content": "你好,我的名字是张三"}]},
    config
)
print(f"AI: {response1['messages'][-1].content}")

# 第二次对话(相同 thread_id)
response2 = graph.invoke(
    {"messages": [{"role": "user", "content": "我的名字是什么?"}]},
    config  # 使用相同的 thread_id
)
print(f"AI: {response2['messages'][-1].content}")

# 获取当前的状态信息
print(f"AI: {graph.get_state(config)}")

除此之外,可以 graph.get_state() / graph.get_state_history() 拿到当前/历史状态;也可以基于 checkpoint 做 replay、update_state(时间旅行能力通常要求启用 checkpointer)。

Super-step 原子循环单元

由于一个 node 也可以连接多个 node,多个 node 也可以连接到 一个 node,所以 LangGraph 设计了 Super-step 来作为原子循环单元。比如下面的例子:

  graph.set_entry_point("n1")
  graph.add_edge("n1", "n2")
  graph.add_edge("n1", "n3")
  graph.add_edge("n2", "n4")
  graph.add_edge("n3", "n4")
  graph.add_edge("n4", END)

LangGraph 只分了三步就执行完了该循环。如下图,第二步的时候会 n2、n3 节点并行执行。

graph active nodes in each superstep

并且每个 super-step 都会自动保存一个 checkpoint,这就是持久化机制的基础。即使程序中断,也能从最后一个 super-step 的 checkpoint 恢复执行。

Human-in-the-loop 人机协同

Human-in-the-loop 本质上就是让 agent “关键时刻”暂停,它的底层靠的是 interrupt + 持久化(checkpoint):暂停时把状态存起来,恢复时从存档续跑。

比如我们想要是线一个场景就是让 AI 去判断是否应该要人工审核,如过需要人工审核,那么就 interrupt 进行中断,然后等人工输入之后根据执行逻辑进行恢复,然后配合Command(resume=...) 恢复。

基本流程可以是这样:

import uuid
from langgraph.types import interrupt, Command

def ask_human(state):
    answer = interrupt("Do you approve?")
    return {"approved": answer}

config = {"configurable": {"thread_id": str(uuid.uuid4())}}

# 第一次跑:会中断,返回 __interrupt__
graph.invoke({"input": "x"}, config=config)

# 人给了答复后:用 Command(resume=...) 恢复
graph.invoke(Command(resume=True), config=config)

这个例子中interrupt()暂停图执行,把一个值(必须可 JSON 序列化)抛给调用方,并依赖 checkpointer 持久化状态;然后你用同一个 thread_id 重新调用图,并传入 Command(resume=...) 来继续。

接下来我们看一个完整的例子,设计一个常见的场景,当模型觉得需要“找专家/找人类”时,会调用一个工具 human_assistance,而这个工具会用 interrupt() 把流程暂停下来,等你在命令行里输入专家建议后,再用 Command(resume=...) 把图唤醒继续跑。

from langgraph.checkpoint.memory import InMemorySaver
from langchain_openai import ChatOpenAI
from typing_extensions import TypedDict
from typing import Annotated
from langgraph.graph import StateGraph, START, END
from langgraph.graph.message import add_messages
from langgraph.types import Command, interrupt
from langchain_core.tools import tool
from langgraph.prebuilt import ToolNode, tools_condition 
from langchain_core.messages import HumanMessage

llm = ChatOpenAI(
    temperature=0.6,
    model="glm-4.6v",
    openai_api_key="",
    openai_api_base="https://open.bigmodel.cn/api/paas/v4/"
)

class State(TypedDict):
    messages: Annotated[list, add_messages]

@tool
def human_assistance(query: str) -> str:
    """Request assistance from a human."""
    human_response = interrupt({"query": query})
    return human_response["data"]

tools = [human_assistance]
llm_with_tools = llm.bind_tools(tools)

def chatbot(state: State):
    message = llm_with_tools.invoke(state["messages"])
    return {"messages": [message]}

tool_node = ToolNode(tools=tools)

graph_builder = StateGraph(State)
graph_builder.add_node("tools", tool_node)

graph_builder.add_conditional_edges(
    "chatbot",
    tools_condition,
)

graph_builder.add_node("chatbot", chatbot)
graph_builder.add_edge(START, "chatbot")
graph_builder.add_edge("chatbot", END)

memory = InMemorySaver()
graph = graph_builder.compile(checkpointer=memory)

config = {"configurable": {"thread_id": "test_thread_123"}}

# 第一步:用户提出一个需要“人工协助”的问题
print("--- 第一阶段:AI 运行并遇到 interrupt ---")
initial_input = HumanMessage(content="你好,帮我找个专家回答我的问题")

for event in graph.stream({"messages": [initial_input]}, config, stream_mode="values"):
    if "messages" in event:
        event["messages"][-1].pretty_print()

# 此时,你会发现程序停止了,因为它卡在 `human_assistance` 的 `interrupt` 处。

# 第二步:模拟人类(你)在一段时间后看到了请求并回复
print("\n--- 第二阶段:模拟人类介入并提供答案 ---")

# 我们构造一个 Command 对象来“唤醒”它
# resume 里的内容会直接成为 interrupt() 函数的返回值
expert_input = input("专家建议: ")
human_feedback = {"data": expert_input}

for event in graph.stream(
    Command(resume=human_feedback), # 这里是恢复运行的关键
    config,
    stream_mode="values"
):
    if "messages" in event:
        event["messages"][-1].pretty_print()

snapshot = graph.get_state(config)

print(snapshot.values)

LangGraph如何轻松实现 Agent 多种执行范式

ReAct

ReAct由Shunyu Yao于2022年提出[1],其核心思想是模仿人类解决问题的方式,将推理 (Reasoning)行动 (Acting) 显式地结合起来,形成一个“思考-行动-观察”的循环。

img

ReAct范式通过一种特殊的提示工程来引导模型,使其每一步的输出都遵循一个固定的轨迹:

  • Thought (思考): 这是智能体的“内心独白”。它会分析当前情况、分解任务、制定下一步计划,或者反思上一步的结果。
  • Action (行动): 这是智能体决定采取的具体动作,通常是调用一个外部工具API 。
  • Observation (观察): 这是执行Action后从外部工具返回的结果,例如或API的返回值。

智能体将不断重复这个 Thought -> Action -> Observation 的循环,将新的观察结果追加到历史记录中,形成一个不断增长的上下文,直到它在Thought中认为已经找到了最终答案,然后输出结果。

from typing import Annotated, Literal
from typing_extensions import TypedDict
from langgraph.graph import StateGraph, END
from langgraph.graph.message import add_messages
from langgraph.prebuilt import ToolNode
from langchain_openai import ChatOpenAI
from langchain_core.tools import tool

# --- 1. 双手:定义查天气的工具 ---
@tool
def get_weather(city: str):
    """查询指定城市的天气"""
    # 这里模拟后端 API 返回数据
    if "北京" in city:
        return "晴天,25度"
    return "阴天,20度"

tools = [get_weather]
tool_node = ToolNode(tools)

# --- 2. 记忆:定义存储对话的状态 ---
class State(TypedDict):
    messages: Annotated[list, add_messages]

# --- 3. 大脑:定义思考逻辑 ---
model = ChatOpenAI(
    temperature=0.6,
    model="glm-4.6v",
    openai_api_key="",
    openai_api_base="https://open.bigmodel.cn/api/paas/v4/"
).bind_tools(tools)

def call_model(state: State):
    # 大脑看一眼目前的对话,决定是直接说话还是去用手拿工具
    return {"messages": [model.invoke(state["messages"])]}

# --- 4. 路由:判断下一步是干活还是结束 ---
def should_continue(state: State):
    last_message = state["messages"][-1]
    # 如果大脑发出的指令包含“调用工具”,就去 tools 节点
    if last_message.tool_calls:
        return "tools"
    # 如果大脑直接说话了,就结束
    return END

# --- 5. 编排图(把脑和手连起来) ---
workflow = StateGraph(State)

workflow.add_node("agent", call_model)
workflow.add_node("tools", tool_node)

workflow.set_entry_point("agent")

# 条件边:agent 运行完,判断是去 tools 还是结束
workflow.add_conditional_edges("agent", should_continue)

# 普通边:tools 运行完(干完活了),必须把结果拿回给 agent 看
workflow.add_edge("tools", "agent")

app = workflow.compile()

# --- 6. 执行测试 ---
for chunk in app.stream({"messages": [("user", "北京今天天气怎么样?")]}):
    print(chunk)

Plan-and-Solve

Plan-and-Solve 顾名思义,这种范式将任务处理明确地分为两个阶段:先规划 (Plan),后执行 (Solve)。Plan-and-Solve Prompting 由 Lei Wang 在2023年提出。其核心动机是为了解决思维链在处理多步骤、复杂问题时容易“偏离轨道”的问题。

Plan-and-Solve 将整个流程解耦为两个核心阶段:

  1. 规划阶段 (Planning Phase): 首先,智能体会接收用户的完整问题。它的第一个任务不是直接去解决问题或调用工具,而是将问题分解,并制定出一个清晰、分步骤的行动计划
  2. 执行阶段 (Solving Phase): 在获得完整的计划后,智能体进入执行阶段。它会严格按照计划中的步骤,逐一执行。每一步的执行都可能是一次独立的 LLM 调用,或者是对上一步结果的加工处理,直到计划中的所有步骤都完成,最终得出答案。

img

import operator
from typing import Annotated, List, Tuple, Union
from typing_extensions import TypedDict
from pydantic import BaseModel, Field
from langgraph.graph import StateGraph, START, END
from langchain_openai import ChatOpenAI

# 1. 定义状态 (State)
class PlanExecuteState(TypedDict):
    input: str            # 原始问题
    plan: List[str]       # 当前待办清单
    past_steps: Annotated[List[Tuple], operator.add] # 已完成的步骤和结果
    response: str         # 最终答案

# 2. 定义结构化输出模型 (用于 Planner)
class Plan(BaseModel):
    """步骤清单"""
    steps: List[str] = Field(description="为了回答问题需要执行的步骤")

# 3. 定义节点逻辑
model = ChatOpenAI(
    temperature=0.6,
    model="glm-4.6v",
    openai_api_key="",
    openai_api_base="https://open.bigmodel.cn/api/paas/v4/"
)

planner_model = model.with_structured_output(Plan, method="function_calling")

# --- 节点 A: 规划者 ---
def planner_node(state: PlanExecuteState):
    plan = planner_model.invoke(f"针对以下问题制定计划: {state['input']}")
    return {"plan": plan.steps}

# --- 节点 B: 执行者 (这里简化了工具调用) ---
def executor_node(state: PlanExecuteState):
    step = state["plan"][0] # 取当前第一步
    print(f"--- 正在执行: {step} ---")
    # 模拟工具执行结果
    result = f"已完成 {step} 的查询,结果为: [模拟数据]"
    return {"past_steps": [(step, result)], "plan": state["plan"][1:]}

# --- 节点 C: 重规划者 (决定是继续还是结束) ---
def replanner_node(state: PlanExecuteState):
    if not state["plan"]:  # 如果清单空了,让 AI 生成最终总结
        summary = model.invoke(
            f"请基于已完成的步骤和结果给出最终答案:{state['past_steps']}"
        )
        return {"response": summary.content}
    return {"response": None}

# 4. 路由逻辑
def should_continue(state: PlanExecuteState):
    if state["response"]:
        return END
    return "executor"

# 5. 编排图
workflow = StateGraph(PlanExecuteState)

workflow.add_node("planner", planner_node)
workflow.add_node("executor", executor_node)
workflow.add_node("re-planner", replanner_node)

workflow.set_entry_point("planner")
workflow.add_edge("planner", "executor")
workflow.add_edge("executor", "re-planner")

# 循环逻辑:根据 re-planner 的判断决定是否回 executor
workflow.add_conditional_edges("re-planner", should_continue)

app = workflow.compile()

# 6. 测试
input_query = {"input": "对比北京和上海的天气,哪个更热?"}
for event in app.stream(input_query):
    print(event)

Reflection

Reflection 机制的核心思想,正是为智能体引入一种事后(post-hoc)的自我校正循环,使其能够像人类一样,审视自己的工作,发现不足,并进行迭代优化。 Reflection 框架是Shinn, Noah 在2023年提出,其核心工作流程可以概括为一个简洁的三步循环:执行 -> 反思 -> 优化

  1. 执行 (Execution):首先,智能体使用我们熟悉的方法(如 ReAct 或 Plan-and-Solve)尝试完成任务,生成一个初步的解决方案或行动轨迹。这可以看作是“初稿”。
  2. 反思 (Reflection):接着,智能体进入反思阶段。它会调用一个独立的、或者带有特殊提示词的大语言模型实例,来扮演一个“评审员”的角色。这个“评审员”会审视第一步生成的“初稿”,并从多个维度进行评估,例如:
    • 事实性错误:是否存在与常识或已知事实相悖的内容?
    • 逻辑漏洞:推理过程是否存在不连贯或矛盾之处?
    • 效率问题:是否有更直接、更简洁的路径来完成任务?
    • 遗漏信息:是否忽略了问题的某些关键约束或方面? 根据评估,它会生成一段结构化的反馈 (Feedback),指出具体的问题所在和改进建议。
  3. 优化 (Refinement):最后,智能体将“初稿”和“反馈”作为新的上下文,再次调用大语言模型,要求它根据反馈内容对初稿进行修正,生成一个更完善的“修订稿”。

img

from typing import TypedDict
from langchain_openai import ChatOpenAI
from langgraph.graph import StateGraph, START, END

class ReflectionState(TypedDict):
    prompt: str
    draft: str
    critique: str
    final: str
    iteration: int

llm = ChatOpenAI(
    temperature=0.6,
    model="glm-4.6v",
    openai_api_key="",
    openai_api_base="https://open.bigmodel.cn/api/paas/v4/",
)

MAX_ITERS = 2

def generate_draft(state: ReflectionState):
    msg = llm.invoke(f"请写一段简短答案:{state['prompt']}")
    return {"draft": msg.content, "iteration": 0}

def reflect_on_draft(state: ReflectionState):
    prompt = (
        "你是严格的审稿人。请指出这段答案的问题并给出改进建议。"
        "如果没有明显问题,请只输出 NO_ISSUES。\n\n"
        f"答案:\n{state['draft']}"
    )
    critique = llm.invoke(prompt)
    print(f"--- 正在执行: reflect,critique:\n {critique.content} ---")
    return {"critique": critique.content}

def revise_draft(state: ReflectionState):
    prompt = (
        "请根据以下反馈重写答案,保持简短清晰:\n\n"
        f"反馈:\n{state['critique']}\n\n"
        f"原答案:\n{state['draft']}"
    )
    revision = llm.invoke(prompt)
    print(f"--- 正在执行: revise,原答案:\n{state['draft']},改进后:\n{revision.content} ---")
    return {"draft": revision.content, "iteration": state["iteration"] + 1}

def finalize(state: ReflectionState):
    return {"final": state["draft"]}

def should_reflect(state: ReflectionState):
    if state["critique"].strip() == "NO_ISSUES":
        return "finalize"
    if state["iteration"] >= MAX_ITERS:
        return "finalize"
    return "revise"

workflow = StateGraph(ReflectionState)
workflow.add_node("generate", generate_draft)
workflow.add_node("reflect", reflect_on_draft)
workflow.add_node("revise", revise_draft)
workflow.add_node("finalize", finalize)

workflow.add_edge(START, "generate")
workflow.add_edge("generate", "reflect")
workflow.add_conditional_edges("reflect", should_reflect)
workflow.add_edge("revise", "reflect")
workflow.add_edge("finalize", END)

app = workflow.compile()

if __name__ == "__main__":
    input_data = {"prompt": "用三句话解释什么是 LangGraph。"}
    result = app.invoke(input_data)
    print("最终答案:")
    print(result["final"])

大致流程就是,首先里面需要有两个角色:写稿人和审稿人,然后用 should_reflect 来判断是否需要重写,然后用 MAX_ITERS 来限制一下最大撰写次数。

  [START]
     |
     v
  (generate_draft)
     |
     v
  (reflect_on_draft) -- NO_ISSUES --> (finalize) --> [END]
           |
           | iteration >= MAX_ITERS
           +-----------------------> (finalize) --> [END]
           |
           +-- else --> (revise_draft) --+
                                         |
                                         v
                                 (reflect_on_draft)

Multi-Agent Pattern

Multi-Agent 模式是将复杂的任务拆解为多个专门化、独立且可协同的微服务,每个服务(Agent)只负责一个特定的领域。

因为单个 Prompt 包含太多工具和指令会导致 LLM “迷失”,模型表现下降。所以通过使用doge Agent 进行职责分离,不同的 Agent 可以使用不同的 Prompt、不同的模型(如 GPT-4o 负责决策,Llama-3 负责写代码),甚至不同的工具集。

比如下面的例子中:一个“PM” Agent 负责拆解任务,并将子任务分发给不同的“员工(Workers)”。

img

img

from typing import Annotated, Literal
from typing_extensions import TypedDict

from langchain_openai import ChatOpenAI
from langgraph.graph import StateGraph, START, END
from langgraph.graph.message import add_messages

# Multi-agent pattern: a supervisor routes work between specialist agents.

llm = ChatOpenAI(
    temperature=0.4,
    model="glm-4.6v",
    openai_api_key="",
    openai_api_base="https://open.bigmodel.cn/api/paas/v4/",
)

class State(TypedDict):
    messages: Annotated[list, add_messages]
    next: str
    turn: int

MAX_TURNS = 6

def _call_agent(system_prompt: str, messages: list, name: str):
    response = llm.invoke([{"role": "system", "content": system_prompt}] + messages)
    return {
        "messages": [
            {"role": "assistant", "name": name, "content": response.content}
        ],
        "turn": 1,
    }

def supervisor(state: State):
    if state["turn"] >= MAX_TURNS:
        return {"next": "finish"}

    system = (
        "You are a supervisor managing a team: researcher, writer, critic. "
        "Choose who should act next or finish. "
        "Respond with exactly one word: researcher, writer, critic, finish."
    )
    response = llm.invoke([{"role": "system", "content": system}] + state["messages"])
    decision = response.content.strip().lower()
    for option in ("researcher", "writer", "critic", "finish"):
        if option in decision:
            return {"next": option}
    return {"next": "finish"}

def researcher(state: State):
    system = (
        "You are a researcher. Gather key facts and constraints for the task. "
        "Be concise and list only essential points."
    )
    return _call_agent(system, state["messages"], "researcher")

def writer(state: State):
    system = (
        "You are a writer. Produce a clear, structured response using the context. "
        "If facts are missing, note assumptions."
    )
    return _call_agent(system, state["messages"], "writer")

def critic(state: State):
    system = (
        "You are a critic. Identify gaps, risks, or unclear parts in the draft, "
        "then suggest improvements."
    )
    return _call_agent(system, state["messages"], "critic")

def route_next(state: State) -> Literal["researcher", "writer", "critic", "finish"]:
    return state["next"]

builder = StateGraph(State)
builder.add_node("supervisor", supervisor)
builder.add_node("researcher", researcher)
builder.add_node("writer", writer)
builder.add_node("critic", critic)

builder.add_edge(START, "supervisor")
builder.add_conditional_edges(
    "supervisor",
    route_next,
    {
        "researcher": "researcher",
        "writer": "writer",
        "critic": "critic",
        "finish": END,
    },
)
builder.add_edge("researcher", "supervisor")
builder.add_edge("writer", "supervisor")
builder.add_edge("critic", "supervisor")

app = builder.compile()

if __name__ == "__main__":
    user_input = "创建一款广告招商的帖子"
    initial_state = {
        "messages": [{"role": "user", "content": user_input}],
        "next": "supervisor",
        "turn": 0,
    }
    for event in app.stream(initial_state):
        for value in event.values():
            if "messages" in value:
                msg = value["messages"][-1]
                name = msg.get("name", "assistant")
                print(f"[{name}] {msg['content']}")

Reference

https://datawhalechina.github.io/

https://www.philschmid.de/agentic-pattern

https://blog.dailydoseofds.com/p/5-agentic-ai-design-patterns

https://zhuanlan.zhihu.com/p/1972437682400519404

https://www.zhihu.com/people/yuan-chelsea

https://ywctech.net/ml-ai/langchain-langgraph-agent-part2

LangGraph 是如何让LLM产生确定性输出的?最先出现在luozhiyun`s Blog

从 MCP 到 Agent Skills

2025-12-28 21:35:01

什么是 agent skills?

Agent Skills 是一种轻量级、开放的格式,用于扩展 AI Agent 的能力和专业知识。本质上,一个 Skill 就是一个包含 SKILL.md 文件的文件夹

Agent Skills 的作用在于:

  • 专业知识封装:将特定领域的程序化知识(procedural knowledge)和公司/团队/用户特定的上下文打包
  • 按需加载:Agent 启动时只加载 Skill 的 namedescription,任务匹配时才加载完整指令
  • 可执行能力:可以包含脚本、工具,扩展 Agent 的实际操作能力
  • 版本化管理:Skills 就是文件,易于编辑、版本控制和分享

那么这里就有个问题,为什么有了 MCP 之后还需要 Agent Skills?

这个问题其实有过很多争论,有开发者评论说:"Skill 和 MCP 是两种东西,Skill 是领域知识,告诉模型该如何做,本质上是高级 Prompt;而 MCP 对接外部工具和数据。" 也有人认为:"从 Function Call 到 Tool Call 到 MCP 到 Skill,核心大差不差,就是工程实践和表现形式的优化演进。"

其实我还是觉得要从 MCP 和 Agent Skills 设计上区分他们到底有什么不同。

MCP 其实就是指的提供了一个远程的接口,可以用这个接口来接外部世界:能读取数据库、能访问 API、能执行命令行;

Agent Skills 更像一个操作手册,主要存在本地的文件里面,不需要调用外部接口,主要是用来告诉 AI 有那些领域知识,然后教 AI 如何正确、高效地使用这些手,按照什么步骤去完成特定任务。

除此之外,Agent Skills 解决了 MCP 无法解决的三个核心问题:

  1. 节省 token

    在使用 mcp 工具的时候,通常工具的定义(名字、参数、描述)全部塞进 AI 的提示词(Prompt)里,AI 才能知道怎么调用。这会极大地消耗 Token,可能占用数万个 token。据社区开发者反馈,仅加载一个 Playwright MCP 服务器就会占用 200k 上下文窗口的 8%,这在多轮对话中会迅速累积,导致成本飙升和推理能力下降。

    而对于 Agent Skills 来说通过渐进式披露(Progressive Disclosure)机制,智能体按需逐步加载,既确保必要时不遗漏细节,又避免一次性将过多内容塞入上下文窗口,来解决这个问题。

  2. 解决“会用工具但不懂业务”的问题(业务流程固化)

    AI 只懂 MCP 是不会理解业务的,比如 MCP 提供了 delete_database()(删除数据库)的工具。这很强大,但也危险。AI 可能因为你的一个模糊指令直接删库。这个时候就可以写一个 Skill,规定:

    当用户要求删除数据库时,必须严格执行以下流程:

    1. 先调用 MCP 工具 check_backup() 确认有备份。
    2. 再调用 MCP 工具 send_alert() 给管理员发通知。
    3. 最后才允许调用 delete_database()
  3. 降低开发门槛

    开发一个 MCP Server 需要后端开发能力,提供接口。Skills 只需要提供 SKILL.md 即可。比如你是资深运营,你可以写一个“小红书文案 Skill”,里面不需要代码,只需要写清楚:“第一步先分析竞品,第二步提取关键词,第三步套用这个模板…”。

所以综上 Agent Skills 至少为开发者带来三大核心价值:

  • 能力复用:一次编写,在 Copilot、Cursor、Claude 等多个 Agent 产品中使用,还可跨团队共享或通过 GitHub 公开发布。

  • 知识沉淀:将团队最佳实践固化为版本化的 Skills,如代码审查规范、部署流程、数据分析模板等,确保工作流程的一致性。

  • 提升效率:通过明确的指导让 Agent 更准确地执行复杂任务,减少试错和修正,提供一致的输出质量。

设计理念

Agent Skills 最核心的创新是渐进式披露(Progressive Disclosure)机制。AI 在使用 Agent Skills 的时候并没有将整个知识库加载到人工智能有限的上下文窗口中,而是以智能的、高效的层级方式加载信息。

  • 第一层:元数据(Metadata):首先只看到每个可用Agent Skills的名称和描述,也就是 Frontmatter buff。这种轻量级信息使其能够快速识别哪些技能可能与用户的请求相关,而不会消耗大量资源。
  • 第二层:技能主体(Instructions):一旦确定了相关技能,AI 就会读取主 SKILL.md 文件。该文件包含执行任务的分步指令和核心逻辑。
  • 第三层:附加资源(Scripts & References):如果说明中提到了其他文件(例如用于数据验证的 Python 脚本或报告模板),AI 会根据需要访问这些特定资源。

这种分层方法使得整个 Agent Skills 系统具有极高的可扩展性。关键在于,当Agent Skills执行脚本时,代码本身永远不会进入上下文窗口;只有脚本的输出才会进入。

社区开发者分享的实践案例充分证明了渐进式披露的威力。在一个真实场景中:

  • 传统 MCP 方式:直接连接一个包含大量工具定义的 MCP 服务器,初始加载消耗16,000 个 token
  • Skills 包装后:创建一个简单的 Skill 作为"网关",仅在 Frontmatter 中描述功能,初始消耗仅500 个 token

如何实现一个

一个最简单的 agent skill 其实只需要包含一个 SKILL.md 文件即可,其他的 scripts、 references、assets 都是可选的。

my-cool-skill/          <-- 技能文件夹
├── SKILL.md            <-- 核心文件:包含元数据和指令(必须)
├── scripts/            <-- (可选) 包含 Python/Bash 脚本
│   └── analyze.py
└── templates/          <-- (可选) 包含模板文件
│   └── report.md
└── assets/             <-- Optional: 一些模版资源

编写 SKILL.md

文件内容分为两部分:YAML 头信息 (Frontmatter)Markdown 正文

A. Frontmatter 头信息(必填)

Frontmatter 必须用 — 包裹起来,像下面这样,最短可以只包含 name 和 description 两个字段:

---
name: skill-name
description: A description of what this skill does and when to use it.
---

name 字段规则

  • 长度:1-64 字符
  • 字符:仅允许小写字母、数字和连字符 (a-z, -)
  • 不能以连字符开头或结尾
  • 不能包含连续的连字符 (--)
  • 必须与父目录名完全一致

description 字段规则

  • 长度:1-1024 字符
  • 应说明技能的功能和使用时机
  • 包含关键词帮助 Agent 识别适用场景

包含可选字段:

---
name: pdf-processing
description: Extract text and tables from PDF files, fill forms, merge documents.
license: Apache-2.0
metadata:
  author: example-org
  version: "1.0"
---

B. 正文 (Instructions)

这是 AI 加载技能后看到的具体操作指南。包括步骤、规则、输入输出格式等。

比如我想要写个示例:

构建一个“代码审查专家”技能。
假设你想创建一个技能,专门用来按你团队的风格审查 Python 代码。

那么我们可以写一个这样的 SKILL.md

---
name: python-code-review
description: 当用户要求审查 Python 代码,或者需要检查代码质量、寻找 bug 时使用此技能。不要用于其他语言。
---

# Python Code Review Guidelines

作为 Python 代码审查专家,请遵循以下步骤审查代码:

## 1. 核心原则
- **类型提示 (Type Hints)**: 所有函数必须包含参数和返回值的类型提示。
- **文档字符串 (Docstrings)**: 使用 Google 风格的文档字符串。
- **错误处理**: 检查是否使用了裸露的 `except:`,必须捕获特定异常。

## 2. 审查清单
请按以下顺序检查代码:
1. 运行静态分析逻辑(如果在 scripts/ 文件夹中有 lint 脚本,请优先参考)。
2. 检查变量命名是否符合 snake_case。
3. 寻找潜在的 N+1 查询问题(如果涉及数据库)。

## 3. 输出格式
请以以下格式输出审查报告:

**🔍 审查摘要**
- 评分: [1-10]
- 主要问题: [摘要]

**📝 详细建议**
| 行号 | 问题 | 建议修改 | 优先级 |
|------|------|----------|--------|
| 12   | 缺少类型提示 | `def func(a: int) -> str:` | 高 |

最佳实践 (Best Practices)

为了让 Skill 更聪明、更好用:

  1. 精确的 Description: AI 只有在 description 与用户请求匹配时才会加载这个技能,尽量应包含:

    • 动作关键词:split, extract, convert, merge
    • 领域关键词:PDF, document, pages
    • 场景关键词:when you need to divide, when working with
    ❌ 坏的写法: "一个帮助代码的工具"
    ✅ 好的写法: "当用户需要根据 PEP8 标准审查 Python 代码并生成表格报告时使用。"
  2. 提供清晰的示例,在 SKILL.md 正文中提供:

    • 常见用例的示例
    • 不同参数组合的效果
    • 预期的输出格式
  3. 原子化: 一个 Skill 最好只做一件事(例如:一个 Skill 做代码审查,另一个 Skill 做文档生成),不要把所有功能塞进一个文件。

  4. 对于复杂的 Skill,将详细文档分离,可以使用 References 目录:

    data-analysis/
    ├── SKILL.md           # 简要说明和快速开始
    ├── scripts/
    │   └── analyze.cs
    └── references/
       ├── REFERENCE.md   # 详细 API 参考
       ├── examples.md    # 更多示例
       └── algorithms.md  # 算法说明

安装和使用 Agent Skills

步骤一:下载

Agent Skills 和 MCP 一样都是 anthropics 公司提出来的,所以他们也提供了很多好用的 skills 供大家使用,如果选择将官方 Skills 安装到当前项目,就在终端输入这条命令:

openskills install anthropics/skills

安装成功后,你就会在Cursor、Trae等工具的文件管理区看到 .claude/skills 的文件夹。

当然也可去下面三方的收集网站上面下载别人写好的 skills:

https://skillsmp.com

https://agentskills.so/

步骤二:配置

先在项目根目录创建一个 AGENTS.md 文件,然后运行

openskills sync

确认后按回车键,你选择的 Skills 就会写进之前空白的 AGENTS.md 文档中。它将作为 Cursor、Trae 等 Coding Agent 接下来使用 Skills 的指导文件。

步骤二:调用

Skills 是可以被自动调用的,如果你想手动调用,可以直接在提示词中指定要调用的具体 Skills,比如:

调用 frontend-design skills,用HTML开发一个视频剪辑软件的SaaS介绍页

最后

最后在举个例子 MCP 如何协同 Agent Skills 一起完成工作。想象一下要实现一个自动化的金融分析代理:

  • MCP 层: IT 部门将内部财务数据库和报表 API 以安全的 MCP 服务器的形式对外开放。这使得 AI 能够直接访问财务数据并生成文件。
  • Agent Skill 层:该技能包含财务的领域知识,包含公司专有分析方法的精确分步说明。它告诉 AI 要查询哪些数据库表(通过 MCP 工具)、如何执行特定计算、如何构建分析以及最终演示文稿应采用何种格式。

在这种情况下,MCP 处理底层标准化的工具调用任务,而Agent Skills负责协调高层智能工作流程。 Agent Skills是一种可重用的资产,它捕捉了分析师的独特专业知识,使其能够立即扩展。这种强大的组合使开发人员能够构建健壮的系统,其中任务逻辑与其所用工具的实现完全分离。

Reference

https://agentskills.io

https://agentskills.so/

https://skillsmp.com

https://www.cnblogs.com/sheng-jie/p/19381647

https://zhuanlan.zhihu.com/p/1986802048608527579

https://www.zhihu.com/question/1890546618509538123

https://agentskills.so/skills-blogs/agent-skills-compare-mcp

从 MCP 到 Agent Skills最先出现在luozhiyun`s Blog

探究Rust中有趣的设计

2025-12-20 17:50:28

这篇文章主要是看一下Rust有哪些比较有意思的设计,相比其他语言之下为什么要这么设计。

变量的可变性

Rust 的变量在默认情况下是不可变的,但是可以通过 mut 关键字让变量变为可变的,让设计更灵活。也就是说,如果我们这么写,编译会报错:

fn main() {
    let x = 5;
    println!("The value of x is: {}", x);
    x = 6;
    println!("The value of x is: {}", x);
}

报错:

error[E0384]: cannot assign twice to immutable variable `x`
 --> src/main.rs:4:5
  |
2 |     let x = 5;
  |         - first assignment to `x`
3 |     println!("The value of x is: {}", x);
4 |     x = 6;
  |     ^^^^^ cannot assign twice to immutable variable 

这其实和 C++ 和 Go(以及 Java、Python 等绝大多数主流语言)的设计哲学完全相反。C++ 和 Go 默认就是可变的,除非加上 const 表示是个常量。

Rust 这样做主要是为了让代码变得清晰点,降低心智负担 。一个变量往往被多处代码所使用,其中一部分代码假定该变量的值永远不会改变,而另外一部分代码却无情的改变了这个值,在实际开发过程中,这个错误是很难被发现的,特别是在多线程编程中。再来就是编译器优化,如果编译器知道一个变量绝不会变,它可以更激进地进行常量折叠、寄存器分配等优化。

在 Rust 中,可变性很简单,只要在变量名前加一个 mut 即可:

fn main() {
    let mut x = 5;
    println!("The value of x is: {}", x);
    x = 6;
    println!("The value of x is: {}", x);
}

但是 Rust 提供了 Shadowing 的功能:

fn main() {
    let x = 5;
    // x = x + 1; // 报错,不能修改

    let x = x + 1; // 合法!这是一个全新的 x,它遮蔽了旧的 x

    let x = "Hello"; // 甚至可以改变类型!
    println!("{}", x);
}

这点很有意思,在很多语言是不可以这么重复声明变量的。我觉得还是和不可变性有关,既然都不可变了,重复声明也是安全的,并且复用同一个变量名,而不需要想出 x_str, x_int, x_final 这种名字,相对来说代码会简洁一些。

所有权 (Ownership)

现在内存管理一般分为两类:

  1. 手动管理派 (C / C++),申请 (malloc/new),需要手动负责释放 (free/delete),但是这是很痛苦的,有时候忘记释放就会内存泄露,或者释放两次就会导致崩溃或为定义的行为;
  2. 垃圾回收派 (Java / Go / Python),有一个 Runtime 里的 GC (Garbage Collector) 盯着内存,不再用的就自动回收。自动回收也有缺点,需要STW,例如在游戏后端或高频交易中,几毫秒的 GC 卡顿可能就是灾难;

而 Rust 使用所有权 (Ownership)来控制。编译器在编译阶段通过一套严格的规则,自动在合适的地方插入 free 代码。没有运行时 GC,也不依赖手动管理。这个编译器定义的所有权规则有以下几条:

  1. Rust 中每一个值都被一个变量所拥有,该变量被称为值的所有者
  2. 一个值同时只能被一个变量所拥有,或者说一个值只能拥有一个所有者
  3. 当所有者(变量)离开作用域范围时,这个值将被丢弃(drop)

比如这个例子:

fn main() {
    let s1 = String::from("hello");
    let s2 = s1; // 赋值操作

    // println!("{}, world!", s1); // ??? 这里能打印 s1 吗?
}

在 C++ 中,如果 s1 申请的是一个堆上的对象,如果是浅拷贝 (Shallow Copy),s1s2 指向堆上的同一块内存。如果函数结束,析构函数执行两次,导致 Double Free 错误。

在 Rust 中,由于所有权的存在,这一行 let s2 = s1;代码执行后,s1 会当场死亡!发生 所有权转移 (Move)。Rust 认为:堆上的那块 "hello" 内存,现在归 s2 管了。所以如果你后面再用 s1编译直接报错

报错:

error[E0382]: borrow of moved value: `s1`
 --> src/main.rs:5:28
  |
2 |     let s1 = String::from("hello");
  |         -- move occurs because `s1` has type `String`, which does not implement the `Copy` trait
3 |     let s2 = s1;
  |              -- value moved here
4 |
5 |     println!("{}, world!", s1);
  |                            ^^ value borrowed here after move

如果你确实需要两个独立的字符串数据(深拷贝),你需要显式调用 .clone()

let s1 = String::from("hello");
let s2 = s1.clone(); // 在堆上重新开辟内存,复制数据

println!("s1 = {}, s2 = {}", s1, s2); // s1 依然活着

除此之外,要注意栈上的数据 ,对于基本类型,基本类型(存储在栈上),Rust 会自动拷贝,其他的非基本类型会存储在堆上,不能自动拷贝。

let x = 5;
let y = x;

代码首先将 5 绑定到变量 x,接着拷贝 x 的值赋给 y,最终 xy 都等于 5,因为整数是 Rust 基本数据类型,是固定大小的简单值,因此这两个值都是通过自动拷贝的方式来赋值的,都被存在栈中,完全无需在堆上分配内存。

借用(Borrowing)

借用(Borrowing),就是允许你在不获取所有权 (Ownership) 的情况下访问数据。简单来说,借用就是创建数据的引用 (Reference)

借用有两种方式,不可变借用 : &T,可变借用:&mut T在任意给定的作用域中,你只能满足以下两个条件之一:

  1. 拥有 任意数量 的不可变引用 (&T)。
  2. 拥有 唯一一个 可变引用 (&mut T)。

即:要么多读,要么独写,绝不能同时存在,这个规则非常像 读写锁(Read-Write Lock)

比如下面就是合法的借用 (多读):

fn main() {
    let s = String::from("hello"); // s 拥有所有权

    let r1 = &s; // 不可变借用 1
    let r2 = &s; // 不可变借用 2

    // 可以同时存在多个读者
    println!("{}, {}", r1, r2); 
} // 借用结束

合法的借用 (独写):

fn main() {
    let mut s = String::from("hello");

    let r1 = &mut s; // 可变借用
    r1.push_str(", world"); // 修改数据

    println!("{}", r1); 
}

非法的借用 (读写冲突):

fn main() {
    let mut s = String::from("hello");

    let r1 = &s;      // r1 借用以此只读
    let r2 = &mut s;  // 错误!不能在有不可变引用的同时创建可变引用
                      // 因为 r2 可能会改变 s,导致 r1 看到的数据失效或不一致

    println!("{}, {}", r1, r2);
}

需要注意的是,现在的 Rust 编译器非常聪明,它的“作用域”不再仅仅是花括号 {},而是看引用的最后一次使用位置,这叫做 Non-Lexical Lifetimes (NLL)

fn main() {
    let mut s = String::from("hello");

    let r1 = &s; 
    let r2 = &s;
    println!("{} and {}", r1, r2); 
    // --- r1 和 r2 的作用在这里就结束了!因为后面没再用过它们 ---

    let r3 = &mut s; // 现在可以了!
    // 因为上面的不可变引用已经不再使用了,Rust 判定冲突解除。
    println!("{}", r3);
}

Rust 这么做其实也是为了安全,我们看看 C++ 中常见的坑:

// C++ 伪代码
vector<int> v = {1, 2, 3};
int& element = v[0]; // 获取第一个元素的引用

v.push_back(4); 
// 危险!如果 push_back 导致 vector 扩容(重新分配内存),
// 原来的内存被释放,element 现在指向的是垃圾内存。
// 再次访问 element 会导致 Crash。

在 Rust 中:

  • element 是一个不可变借用 (&T)。
  • v.push_back 需要获取 v 的可变借用 (&mut T)。
  • 规则冲突:已经有 & 了,不能再借出 &mut
  • 结果:编译直接报错,阻止隐患。

字符串

&strString

在其他很多语言用 "hello" 这种方式创建的一般就叫字符串,但在 rust 里面不一样,它实际上是申明了一个只读的字符串字面量 &str,这意味着它是不可变的,数据直接硬编码在编译后的可执行文件 (Binary) 中(静态存储区),有点像 const。

let name = "Rust"; // 类型是 &str
println!("Hello, {}", name);
// name.push_str(" World"); // 报错!&str 不能修改

在 rust 里面只有使用 String::from(...) 声明的字符串才是我们常规意义上理解的字符串,比如可以对它进行修改拼接传递

  1. 修改字符串 (必须加 mut)

    fn main() {
       // 注意:如果要修改,必须加 mut 关键字
       let mut s = String::from("Hello");
    
       // 1. 追加字符串切片 push_str()
       s.push_str(", world"); 
    
       // 2. 追加单个字符 push()
       s.push('!'); 
    
       println!("{}", s); // 输出: Hello, world!
    }
  2. 字符串拼接 (连接两个字符串),有两种主要方式:使用 + 运算符或 format! 宏。

    fn main() {
       let s1 = String::from("Tick");
       let s2 = String::from("Tock");
    
       // 注意细节:
       // s1 必须交出所有权 (被移动了),后面不能再用了
       // s2 必须传引用 (&s2)
       let s3 = s1 + " " + &s2; 
       // 类似 C 语言的 sprintf,生成一个新的 String
       let s4 = format!("{} - {}", s1, s2);
    }
  3. String 可以自动假装成 &str

    fn main() {
       let s = String::from("Hello World");
    
       // 场景 1: 函数需要 String (拿走所有权)
       take_ownership(s); 
       // println!("{}", s); // 报错,s 已经被拿走了
    
       // --- 重新创建一个 s ---
       let s = String::from("Hello Again");
    
       // 场景 2: 函数需要 &str (只读借用) -> 【这是最常用的】
       // 虽然 s 是 String,但 &s 可以被当做 &str 用
       borrow_it(&s); 
       println!("s 还在: {}", s); // s 还在
    }
    
    fn take_ownership(input: String) {
       println!("我拿到了所有权: {}", input);
    } // input 在这里被释放
    
    fn borrow_it(input: &str) {
       println!("我只是借看一下: {}", input);
    }
  4. 转换回切片 (Slicing)

    let s = String::from("Hello World");
    
    let hello = &s[0..5]; // 提取前5个字节
    let world = &s[6..];  // 从第6个字节取到最后

需要注意的是,Rust 的字符串在底层是 UTF-8 编码的字节数组,不支持直接通过数组下标索引(Index)访问字符。比如这样是会报错的:

let s1 = String::from("hello");
let h = s1[0];

Rust 的 String 本质上是一个 Vec<u8>(字节向量)。对于纯英文: "hello",每个字母占 1 个字节。s[0] 确实是 'h'对于中文/特殊符号: "你好"。在 UTF-8 中,’你’ 占用 3 个字节。

为了强迫开发者意识到 “字符 ≠ 字节” 这一事实,Rust 干脆在编译阶段就禁止了 String[index] 这种写法。

所以,为了获取第 N 个字符 (最常用,安全),需要使用 .chars() 迭代器:

fn main() {
    let s1 = String::from("hello");

    // .chars() 把字符串解析为 Unicode 字符
    // .nth(0) 取出第 0 个元素
    // 结果是 Option<char>,因为字符串可能是空的
    match s1.chars().nth(0) {
        Some(c) => println!("第一个字符是: {}", c),
        None => println!("字符串是空的"),
    }
}

为什么要有这两个?

  • String 是为了当你需要在运行时动态生成修改或者持有字符串数据时使用的(比如从网络读取数据,拼接 SQL)。
  • &str 是为了高性能传递。当你只需要“看”一下字符串,而不需要拥有它时,用 &str。因为它只是传两个整数(指针+长度),不需要拷贝堆上的数据,速度极快。

枚举

Rust 的枚举和其他语言最大的不同应该就是能挂载数据。每一个枚举成员,都可以关联不同类型、不同数量的数据。

enum Message {
    Quit,                       // 没有关联数据
    Move { x: i32, y: i32 },    // 像 Struct 一样包含命名字段
    Chat(String),               // 包含一个 String
    ChangeColor(i32, i32, i32), // 包含三个 i32
}

fn main() {
    // 创建不同类型的消息
    let msg1 = Message::Move { x: 10, y: 20 };
    let msg2 = Message::Chat(String::from("你好"));
}

比如上面的 Message 这个枚举,Quit 成员没有挂载数据,其他三个都挂载了各不相同的数据类型。

然后我们就可以根据枚举挂载的不同数据,使用 match 来进行匹配,这有点像 C++ 里面的 switch-case:

fn process_message(msg: Message) {
    match msg {
        Message::Quit => {
            println!("玩家退出了");
        }
        Message::Move { x, y } => {
            println!("玩家移动到了: x={}, y={}", x, y);
        }
        Message::Chat(text) => {
            println!("玩家发送消息: {}", text);
        }
        Message::ChangeColor(r, g, b) => {
            println!("更改颜色为: R{} G{} B{}", r, g, b);
        }
    }
}

但是需要注意的是 match 会强制你处理所有可能的情况,如果你漏写了 Message::Quit,代码根本编译不过。这保证了你不会遗漏任何一种业务逻辑。如果不想在匹配时列出所有值的时候,可以使用特殊的模式 _ 替代:

let some_u8_value = 0u8;
match some_u8_value {
    1 => println!("one"),
    3 => println!("three"),
    5 => println!("five"),
    7 => println!("seven"),
    _ => (),
}

顺带提一下,match 其实也可以用来返回赋值,这点就和很多其他语言不同:

enum IpAddr {
   Ipv4,
   Ipv6
}

fn main() {
    let ip1 = IpAddr::Ipv6;
    let ip_str = match ip1 {
        IpAddr::Ipv4 => "127.0.0.1",
        _ => "::1",
    };

    println!("{}", ip_str);
}

这里匹配到 _ 分支,所以将 "::1" 赋值给了 ip_str

上面我们也说了枚举可以挂载数据,所以相应的 match 也能把数据解包出来:

enum Action {
    Say(String),
    MoveTo(i32, i32),
    ChangeColorRGB(u16, u16, u16),
}

fn main() {
    let actions = [
        Action::Say("Hello Rust".to_string()),
        Action::MoveTo(1,2),
        Action::ChangeColorRGB(255,255,0),
    ];
    for action in actions {
        match action {
            Action::Say(s) => {
                println!("{}", s);
            },
            Action::MoveTo(x, y) => {
                println!("point from (0, 0) move to ({}, {})", x, y);
            },
            Action::ChangeColorRGB(r, g, _) => {
                println!("change color into '(r:{}, g:{}, b:0)', 'b' has been ignored",
                    r, g,
                );
            }
        }
    }
}

上面当代码执行到 match action 时 Rust 发现 actionAction::Say 这一类,就会解构这个类型里面的 String,Action::Say(s)的意思是:“如果是 Say,把它肚子里的数据拿出来,赋值给变量 s。”,其他的同理。

Rust 的枚举类型还有一个特点就是结构比较紧凑,枚举 = 标签(tag)+ 数据(payload),比如这个枚举:

enum Message {
    Quit,                    // 0 字节 payload
    Move { x: i32, y: i32 }, // 8 字节
    Write(String),           // 24 字节(64位平台上,大概是 3 个指针)
}

tag 就是当前是 Quit/Move/Write 中的哪一个,数据(payload)就是容纳“所有变体里最大的那个数据”的空间,这里就是24字节的 String 类型,当然还有根据对齐要求,可能在 tag 和 payload 之间加 padding,那么一个枚举的结构就是:

size_of::<Message>() ≈ size_of::<payload最大> + size_of::<tag> + 对齐填充

所以可以看到枚举的结构比使用结构体更加紧凑。

最后,枚举甚至还能可以有自己的方法:

#![allow(unused)]
enum Message {
    Quit,
    Move { x: i32, y: i32 },
    Write(String),
    ChangeColor(i32, i32, i32),
}

impl Message {
    fn call(&self) {
        // 在这里定义方法体
    }
}

fn main() {
    let m = Message::Write(String::from("hello"));
    m.call();
}

null

Tony Hoare, null 的发明者,曾经说过一段非常有名的话:

我称之为我十亿美元的错误。当时,我在使用一个面向对象语言设计第一个综合性的面向引用的类型系统。我的目标是通过编译器的自动检查来保证所有引用的使用都应该是绝对安全的。不过在设计过程中,我未能抵抗住诱惑,引入了空引用的概念,因为它非常容易实现。就是因为这个决策,引发了无数错误、漏洞和系统崩溃,在之后的四十多年中造成了数十亿美元的苦痛和伤害。

所以 Rust 只有 Option 枚举,没有 null / nil / nullptr。Rust 标准库是这样定义的:

enum Option<T> {
    None,    // 相当于 null
    Some(T), // 包含一个值
}

Option 可以配合 match 来使用:

let some_number = Some(5);
let absent_number: Option<i32> = None;

// let sum = some_number + 1; // 报错!不能把 Option<i32> 和 i32 相加

// 必须处理为空的情况
match some_number {
    Some(i) => println!("数字是: {}", i),
    None => println!("没有数字"),
}

使用 Option 还有个好处就是编译器会做一种优化叫做 “空指针优化 (Null Pointer Optimization)”

对于 Option<&T>(引用类型的 Option)或 Option<Box<T>>(堆指针的 Option)Rust 编译器在底层依然把它看作一个指针,Some(ptr) 对应非零地址,None 对应 0 (null) 地址。所以在汇编层面,Rust 的 Option<&T> 和 C++ 的 T\* 是一模一样的,内存占用也是一样的(64位机器上都是8字节)。

所以把指针包在 Option 枚举里不会增加内存开销,也不会导致运行变慢。

泛型(Generics)

Rust 泛型其实是有点像C++的模版的,而不是类似 Java 或 C# 的泛型。 Java 泛型是在编译期进行检查,但在运行时被“擦除”。例如,List<String>List<Integer> 在 Java 虚拟机(JVM)看来,本质上都是 List<Object>。这样做优点是节省了代码空间,缺点是牺牲了很多性能,因为在运行的时候虚拟机必须进行大量的类型转换(Casting)。

Rust 泛型则不一样,它和C++的模版是一样的,会根据你传入的具体类型(比如 int/i32float/f64),生成多份不同的机器码。这样做的好处就是执行效率极高,没有 Java/Go 那种装箱(Boxing)或运行时类型断言的开销。

但是相对的,Rust 又比 C++ 模版又要使用上要舒服很多,Rust 泛型因为 Trait Bounds 的原因,所以编译器在定义阶段就能发现错误,而不是等到调用阶段。假设我们要写一个打印函数:

C++:

template <typename T>
void print_it(T value) {
    // 编译器在这里不检查 value 到底有没有 .print() 方法
    // 直到你在 main 函数里调用 print_it(int) 时,它才发现 int 没有 .print()
    value.print(); 
}

Rust:

// 必须显式约束:T 必须实现了 Debug trait
fn print_it<T: std::fmt::Debug>(value: T) {
    println!("{:?}", value);
}

// 如果你写成下面这样,编译器直接报错,甚至不需要你调用它:
// fn print_it<T>(value: T) {
//     println!("{:?}", value); // 错误!编译器说:T 没有实现 Debug,不能打印
// }

特征对象(Trait Objects)

Rust 的特征对象 (dyn Trait) ≈ C++ 的虚基类指针 (Base) ≈ Go 的 interface

在 Rust 中,如果你想编写一个函数,或者定义一个容器(如 Vec),让它可以接受多种不同类型的数据,只要这些数据实现了同一个 Trait,你有两种选择:

  1. 泛型(Generics)fn foo<T: Draw>(x: T)
    • 这是编译期决定的。
    • 编译器会为每个不同的类型生成不同的代码(单态化)。
    • 缺点:你不能在一个 Vec 里同时存 u8f64,因为 Vec 只能存一种类型。
  2. 特征对象(Trait Objects)fn foo(x: &dyn Draw)
    • 这是运行期决定的。
    • “类型擦除”(Type Erasure):编译器此时不再关心具体的类型是 u8 还是 f64,它只关心“这东西能 Draw”。
    • 优点:你可以在一个 Vec<Box<dyn Draw>> 里混存 u8f64

特征对象(Trait Objects)实现的方式其实和 C++ 的虚表实现很像,比如当你把一个具体类型(如 &u8)转换成特征对象(&dyn Draw)时,Rust 会生成一个胖指针(Fat Pointer)

这个胖指针包含两部分(占用 16 字节):

  1. data 指针:指向具体的数据(如堆上的 u8 值)。
  2. vtable 指针:指向该具体类型针对该 Trait 的虚函数表(Virtual Method Table)

当你调用 x.draw() 时,如果 x 是特征对象,机器码执行的逻辑如下:

  1. 读取 vtable:从胖指针的第二个字段找到 vtable 的地址。

  2. 查找方法:在 vtable 中找到 draw 方法对应的函数指针(比如偏移量为 0 的位置)。

  3. 跳转执行:调用该函数指针,并将胖指针的第一个字段(data 指针)作为 self 传进去。

举个例子,如果我们使用泛型来实现下面的 Screen 类,里面的 components 想要放多个元素:

pub struct Screen<T: Draw> {
    pub components: Vec<T>,
}

如果你这样写,Screen 实例被创建时,T 必须被确定为某一种具体的类型。这意味着 components 里的所有元素都必须是同一个类型。如果想要混装是不行的,如下面:

let screen = Screen {
    components: vec![
        10u8,     // 编译器推断 T 是 u8
        3.14f64,  // 报错!期望是 u8,但你给了 f64
    ],
};
// error[E0308]: mismatched types

只能全部都是同一类型:

let screen = Screen {
    components: vec![10u8, 20u8, 30u8], // 全是 u8,没问题
};

如果使用特征对象(Trait Objects),就可以实现混装,在列表中存储多种不同类型的实例。

pub struct Screen {
    pub components: Vec<Box<dyn Draw>>,
}

这样是 ok 的:

let v: Vec<Box<dyn Draw>> = vec![
    Box::new(10u8),
    Box::new(3.14f64),
];

生命周期

生命周期就是控制有效作用域,防止 悬垂引用(Dangling Reference)的 。

fn main() {
    let r;                // ---------+-- r 的生命周期开始
    {                     //          |
        let x = 5;        // -+-- x 的生命周期开始
        r = &x;           //  | 试图让 r 指向 x
    }                     // -+-- x 在这里死了(被释放)!
                          //          |
    println!("r: {}", r); //          | r 依然活着,但它指向的 x 已经没了!
                          //          | -> 报错:借用了活得不够久的值
}                         // ---------+

比如这段代码,Rust 编译器会拒绝编译,因为它发现 r 活得比 x 久,为了安全考虑,然后就拒绝编译。

用过 C++ 的同学知道, 在线上经常会因为这种问题而导致程序的 panic,因为C++ 编译器通常会“相信”你,比如下面的例子编译器根本不会管你:

#include <iostream>
#include <string>

// C++ 代码
const std::string& get_dangling() {
    std::string s = "Hello";
    return s; // 危险!s 在这里会被销毁
}

int main() {
    // 这里拿到了一个引用,指向了一块已经被释放的栈内存
    const std::string& ref = get_dangling(); 

    // 运行时表现:
    // 1. 可能崩溃 (Segmentation Fault)
    // 2. 可能打印出乱码
    // 3. 可能打印出 "Hello" (如果在内存被覆盖前运气好) -> 这是最可怕的“未定义行为”
    std::cout << ref << std::endl; 
}

所以相对而言,Rust 编译器的严格管控,实际上是”为了你好“。

Rust 为了实现这种严格管控,就出现了生命周期标注这种东西。生命周期不是“运行时的计时器”,也不是你手动管理内存的东西。它是编译器做静态分析时用的标记/约束,表示:

  • 这个引用至少要活到哪里
  • 或者:两个引用之间谁不能比谁短

你写的 'a 这种符号,就是一种生命周期参数。大多数时候,Rust 编译器能自动推断生命周期,但在一些模糊的情况下,就需要手动标注。手动标注是以 ' 开头,名称往往是一个单独的小写字母,大多数人都用 'a 来作为生命周期的名称。

关于什么时候需要使用标注其实就一句话:输出的引用到底是从哪个输入里借来的有歧义的时候。具体来说,主要有以下 4 种场景

场景 1:结构体中包含引用

只要你的 struct 定义里包含字段是引用(而不是像 Stringi32 这样的拥有所有权的类型),你就必须给整个结构体加上生命周期参数。

// 报错:missing lifetime specifier
struct Book {
    author: &str, // 这是一个引用,编译器慌了:这个引用指向谁?能活多久?
}

// 正确
// 读作:Book 实例活多久,'a 就得活多久;author 引用的数据至少也要活 'a 这么久。
struct Book<'a> {
    author: &'a str,
}

场景 2:函数有多个引用参数,且返回值也是引用

如果你有两个输入引用,且返回一个引用。编译器就蒙了:“返回的这个引用,是借用了参数 A,还是参数 B?”

// 报错
// 编译器困惑:返回的 &str 到底是谁的?
// 如果 user 活 10s,data 活 5s,我该让返回值活多久?
fn choose_one(user: &str, data: &str) -> &str {
    if user.len() > 5 { user } else { data }
}

// 正确
// 显式告诉编译器:返回值的生命周期取 user 和 data 中较短的那个('a)
fn choose_one<'a>(user: &'a str, data: &'a str) -> &'a str {
    if user.len() > 5 { user } else { data }
}

如果返回值只跟其中一个参数有关,只标那个参数就行:

// 这里的 'a 表示返回值只和 x 有关,和 y 无关
fn verify<'a>(x: &'a str, y: &str) -> &'a str {
    x
}

当然,如果只有一个输入引用那就没有歧义:

fn capitalize(s: &str) -> &str { ... } 
// 编译器自动脑补为:
// fn capitalize<'a>(s: &'a str) -> &'a str { ... }

场景 3:在 impl 块中实现方法时

这是语法要求,防止你忘记这个结构体是有“保质期”的。

struct Book<'a> {
    author: &'a str,
}

// 必须写 impl<'a>,把 'a 声明出来
impl<'a> Book<'a> {
    // 这里的 &self 其实隐含了生命周期
    fn get_author(&self) -> &str {
        self.author
    }
}

注意:在方法内部,通常不需要给参数标生命周期,因为 Rust 有一条强大的规则:“如果是方法(有 &self),那么返回值的生命周期默认和 self 一样。”

Reference

https://course.rs/

探究Rust中有趣的设计最先出现在luozhiyun`s Blog

北大《区块链技术与应用》——ETH篇

2025-11-26 20:17:32

课程地址:https://www.bilibili.com/video/BV1Vt411X7JF?spm_id_from=333.788.videopod.episodes&vd_source=f482469b15d60c5c26eb4833c6698cd5&p=2

ETH发展史

2013年底: Vitalik Buterin(V神)发布了以太坊白皮书。他的核心理念是:比特币像一个功能单一的计算器(可编程货币),而世界需要一个更通用的平台,像一台“世界计算机”(World Computer)。所以他提出了智能合约的概念,指的是运行在区块链上的、图灵完备的程序。通过使用以太坊虚拟机(EVM)提供的这样的沙盒环境,用于执行智能合约。

2015年7月"Frontier"(前线)版本上线。这是以太坊的第一个“创世区块”,标志着网络的正式启动,这是时候还仅仅是一个测试版本。采用的是工作量证明(PoW)的共识机制。

2016年初"Homestead"(家园)版本发布,这是第一个稳定版本,标志着以太坊不再是“测试版”,开始吸引DApp(去中心化应用)构建者。The DAO这个项目诞生了,它是一个去中心化的风险投资基金,通过智能合约管理,它筹集了当时价值约1.5亿美元的ETH。

同时也意味着危机,2016年6月,The DAO 合约遭到“重入攻击”(Re-entrancy Attack),导致约1/3的资金被盗。

所以这个时候社区面临一个哲学困境,是要接受损失,还是进行通过修改协议规则来回滚交易,追回被盗资金。最后社区投票支持硬分叉,追回了资金,成为了今天的主流链,也就是今天的 ETH。另一派坚持不回滚,保留了原始链,就成了另一个币 ETC。

我们接着跳过几年不这么重要的发展期来到PoS时代

为什么要从 PoW 转向 PoS 共识证明呢?我们都知道 PoW 用“工作”来换取记账权,工作量越大,越值得信赖,这就有个问题,需要巨大的电力和硬件成本,这是极度不环保的。

而 PoS 的核心理念是用“抵押”来换取记账权,你抵押的(Stake)越多,越值得信赖。参与者不再需要购买昂贵的矿机,而是需要购买并质押(锁定)网络的原生代币,将这些代币作为“保证金”或“押金”锁在网络中。如果一个验证者试图作恶(例如,提议无效区块、双重签名),网络会自动销毁他质押的“保证金,ETH 就是这样降低了约 99.95% 的能耗。

下面说一下ETH是怎么做到的:

2020年12月, 信标链(Beacon Chain)上线,这是一条独立运行的、采用 PoS 共识的全新区块链,它唯一的任务就是让验证者质押 ETH 并就 PoS 共识达成一致。此时,ETH 质押是单向的(只能存入,不能取出)。

2022年9月15日以太坊团队将原有的 PoW 链(现在称为“执行层”)的“引擎”——即 PoW 共识——拔掉。然后,将“执行层”接入到“信标链”(现在称为“共识层”)的 PoS 引擎上。这正式标志着以太坊进入了“PoS 时代”

合并完成后,2023年4月 "Shapella"(上海 + Capella)发布启用了质押提款(EIP-4895)。验证者终于可以取出他们质押的 ETH 和奖励,这次升级引入了 withdrawalsRoot(提款树根)字段到区块头中。

当然 ETH 的迭代远没有结束,我看社区还在继续讨论新的提案出来。比如 Pectra 、The Verge 与 The Purge 等,感兴趣的可以自行取查阅一下。

账户

这里用 BTC 和 ETH 进行对比,首先在 BTC 中,并不存在一个叫做“我的余额”的变量。它是由 UTXO 算出来的,所以假设钱包里有 5 BTC。这 5 BTC 在区块链上可能并不是一个“5”,而是:

  • 一笔 2 BTC 的“现金”(UTXO 1)
  • 一笔 1.5 BTC 的“现金”(UTXO 2)
  • 一笔 1.5 BTC 的“现金”(UTXO 3)

总余额 = UTXO 1 + UTXO 2 + UTXO 3 = 5 BTC

如果想支付 3 BTC,钱包会选择“消耗”掉 UTXO 1 (2 BTC) 和 UTXO 2 (1.5 BTC),总共 3.5 BTC。然后产生两个新的 UTXO:

  1. 一个 3 BTC 的 UTXO 发送给您的朋友(支付)。
  2. 一个 0.5 BTC 的 UTXO 发送回给您自己(找零)。

旧的 UTXO 1 和 UTXO 2 就被标记为“已花费”,不能再用了。

这种模型的优点: 简单、安全、隐私性相对较好(因为找零地址可以是新地址)、易于并行处理交易。 缺点: 难以实现复杂的逻辑(例如智能合约),因为它很难跟踪一个“账户”的复杂状态。

ETH 的设计更像是传统的银行系统。每个地址都是一个独立的“账户”。如果地址有 5 ETH,那么在以太坊的“全局账本”上,地址旁边就明确写着 balance: 5

如果要支付 3 ETH,发起一笔交易,声明:“从账户A转 3 ETH 到账户B”,网络验证账户余额(5 ETH)是否足够支付 3 ETH(以及手续费 Gas)。验证通过后,以太坊网络会:

  1. 将账户A的 balance 减去 3 ETH。
  2. 将账户B的 balance 加上 3 ETH。

为了防止余额数字被直接篡改,账户里面有 nonce 用来记数,每次交易完毕之后加一,防止重放攻击。

ETH 有两种账户:

外部账户 externally owned account,个人用户钱包,由私钥控制,可以发起交易;

合约账户 smart contract account 由代码(智能合约)控制,没有私钥,它不能主动发起交易,只能在被 EOA 或其他合约“调用”(发送消息)时被动执行其代码。

ETH模型的优点:使得智能合约(复杂的应用程序)成为可能。模型更直观,易于开发 DApps。缺点: 交易必须按顺序处理(因为有 nonce 机制防止重放攻击),这可能导致网络拥堵。

数据结构

image-20251125210438372

Merkel Patricia Trie

账户地址到账户状态的映射 , 账户地址是 160 位。

以太坊 (ETH) 的核心数据结构是 Modified Merkle Patricia Trie, 简称 MPT。我们可以把它拆解成两个关键概念的组合来理解:Merkle Tree (默克尔树)Patricia Trie (帕特里夏·树,或称压缩前缀树)

Merkle Tree (默克尔树):它是一种哈希树。树底部的每个“叶子”是数据块的哈希值。相邻的哈希值两两组合再哈希,层层向上,最终汇聚成一个“根哈希” (Root Hash),如下图:

这种树有一个特点是只要树中的任何一个数据发生(哪怕是 1 bit 的)改变,最终的“根哈希”都会变得完全不同。这使得节点只需比较这一个根哈希,就能快速验证彼此是否拥有完全相同的海量数据。

                                                 +-----------------+
                         |   Merkle Root   |  <- 最终的"指纹" (H_ABCD)
                         | (H_AB + H_CD)   |
                         +-----------------+
                                / \
                               /   \
                +---------------+   +---------------+
                |   Hash_AB     |   |   Hash_CD     |  <- 中间节点
                | (H(T1)+H(T2)) |   | (H(T3)+H(T4)) |
                +---------------+   +---------------+
                      / \                 / \
                     /   \               /   \
            +-------+ +-------+     +-------+ +-------+
            | H(T1) | | H(T2) |     | H(T3) | | H(T4) |  <- 叶子节点
            +-------+ +-------+     +-------+ +-------+
                |         |             |         |
            +-------+ +-------+     +-------+ +-------+
            |  T1   | |  T2   |     |  T3   | |  T4   |  <- 原始数据
            +-------+ +-------+     +-------+ +-------+

比如我们上图有四个数据块 T1, T2, T3, T4,然后计算哈希H(T1), H(T2), H(T3), H(T4)构成叶子节点,然后他们的父节点分别由他们拼接起来再哈希获得。如果有人把 T3 改成了 T3*,那么 H(T3) 会变,Hash_CD 也会变,最终 Merkle Root 会变得完全不同

Patricia Trie (称压缩前缀树)

它其实是 Trie 进化而来的,可以高效地存储和查找键值对 (Key-Value),特别是当“键”(Key) 有相同前缀时,它能极大压缩存储空间。举个例子,比如我们要存储以下几个键值对(以单词为例):

  • "romane": (值 1)
  • "romanus": (值 2)
  • "romulus": (值 3)
  • "rubens": (值 4)
  • "ruber": (值 5)
                                            (Root)
                        |
                      "r"
                      / \
                     /   \
                 "om"     "ub"
                 / \         / \
                /   \       /   \
              "an"  "ulus"  "e"   "ens"  (值 4)
              / \     |       |
             /   \  (值 3)    "r"
           "e"   "us"           |
            |     |           (值 5)
          (值 1) (值 2)

我们可以看到这个树基本上和 Trie 类似,唯一的区别就是对路径进行了压缩,对比普通前缀树会是 R -> O -> M -> A -> N -> E。看,R->OO->M 都是“单行道”,只有一个子节点。Patricia 的压缩它会把这些单行道合并。R 后面有两个分叉 ("o", "u"),所以 "r" 节点保留。但 "r" 之后的 "o" 和 "m" 都是单行道,所以它们被压缩成了 "om" 节点。同理,"rub" 被压缩成了 "ub" 节点。

使用 Patricia Trie结构对 ETH 来说主要有几点好处:

  1. ETH 需要跟踪数以亿计的账户,每个账户都有自己的状态(余额、nonce、合约代码等)。Patricia Trie 可以方便的用来将这些数据组织成 key value 对,比如 key 存的是账户地址 (0x...),value存的是该账户的状态信息。

  2. 并且以太坊的“键”(如账户地址)非常长(160位或更长)。如果使用标准的前缀树,从根节点到每个叶子节点都会有非常多的层级,而Patricia Trie它会把所有“没有分叉的单行道”路径压缩合并成一个节点,从而节省空间。

  3. Trie 树的最终形状和根哈希只取决于它所包含的“键值对”数据,而与插入这些数据的顺序无关。以太坊是一个全球分布式的系统。不同的节点在构建区块时,可能会以不同的顺序处理(本地缓存或插入)状态数据,所以这一点也是至关重要的。

  4. 最后就是 Patricia Trie 允许高效的状态更新,当一笔交易发生时(例如,A 转账给 B),通常只有极少数的“值”被改变了(A的余额减少,B的余额增加),种树形结构允许只更新从被修改的叶子节点到根节点的那条路径上的节点。

在 ETH 结构中,有三棵树都是使用的Patricia Trie结构:交易树 (Transaction Trie)、收据树 (Receipts Trie) 、状态树(State Trie),所以我们来看看 ETH 的 Merkel Patricia Trie 是怎么做的。

image-20251107212146532

ETH 它把键视为 16 进制的 nibble(半字节)序列,用三种节点(Branch / Extension / Leaf)压缩表示键空间,并对每个节点做序列化后取哈希,最后得到整棵树的根哈希,如上图所示。

nibble (半字节)序列简单说就是将输入的 字节流 (byte stream) 转换为 16 进制字符流。因为一个字节 (Byte) 包含 8-bit,而一个半字节 (Nibble) 包含 4-bit,所以每一个字节都会被精确地拆分成两个半字节 (nibbles)。一个 8-bit 的字节,比如 0x7A,就会被拆分成了两个 nibbles:[7, a]

下面我们看看三种节点(Branch / Extension / Leaf):

  • Branch 节点(branch) — 有 16 个子指针 + 一个可选值槽(用于恰好在此处结束的键):

    BranchNode:
    +----------------------------+
    | v0 | v1 | v2 | ... | v15 | value |
    +----------------------------+
    • v0..v15 共 16 个子指针是因为 Key 被拆分成了半字节 (nibbles) ,用十六进制表示,指向下一层节点(分别对应 nibble 0..15),如果没有对应路径则为 null(或空);
    • value 字段用于当某个键恰好在该节点结束(即键完全耗尽)时保存对应值。
  • Extension 节点(extension) — 用于把一段共享前缀聚合成一条边:

    ExtensionNode:
    +----------------------+    指向下一级节点
    | path: [nibble数组]   |  --->  子节点
    +----------------------+      (Branch/Leaf/Extension)
    • path 是一段 nibble(十六进制半字节)序列;extension 不包含值,只是压缩中间相同前缀。
  • Leaf 节点(leaf) — 存储键的剩余部分(从分支到末尾)和对应值:

    LeafNode:
    +---------------------------+
    | path: [剩余 nibble数组]   |
    | value: bytes              |
    +---------------------------+
    • 当一个键在 trie 中一个分支走到底时,用 leaf 存储剩下的 nibble 和最终值。

比如我们把把三个键插入到空的MPT中:

  • Key A:[a, b, c, d] -> 值 V1
  • Key B:[a, b, c, e] -> 值 V2
  • Key C:[a, b, f] -> 值 V3
root = Extension([a,b]) -> Branch
                         /   |   ...
                        c    f
                        |      \
                 Extension([c])  Leaf([ ] -> V3)
                    |
                   Branch
                   /   \
                 d      e
                 |      |
               Leaf   Leaf
               (V1)   (V2)

路径 [a,b] 是共有前缀,[c](成为 extension/直接连接到 branch 继续分叉指向 leaf v1 和 leaf V2,子槽 f 指向 leaf 值是 V3。

Block Header 四颗状态树

在 Block Header 里面有四棵树,状态树 (State Trie)、交易树 (Transactions Trie)、收据树 (Receipts Trie)、提款树 (Withdrawals Trie) 都是用 MPT 来构建的。

  • 状态树 State Trie它记录了所有账户的全局状态(余额、nonce、合约代码、合约存储)。需要注意的是 这是唯一一棵持久化的树。它不只是记录这个区块发生的事,而是记录了在执行完这个区块的所有交易之后,以太坊全世界所有账户(包括智能合约)的最终状态。每个新区块都会在旧状态树的基础上进行“更新”,产生一个新的 stateRoot,其他不变的账号状态还是用原来的节点。

    image-20251107213815983

    主要包含:

    • 每个人的 ETH 余额。
    • 每个账户的 nonce(交易计数)。
    • 每个智能合约的 codeHash(代码)。
    • 每个智能合约的 storageRoot(指向它自己的存储树)。
  • 交易树 (Transactions Trie)里面包含当前区块中的所有交易。它的唯一目的就是按顺序存储仅属于这个区块的所有交易。

    主要包含:

    • 交易 0, 交易 1, 交易 2…
  • 收据树 (Receipts Trie)包含当前区块中所有交易的执行回执(Receipts)。 这棵树对于 DApp 和钱包至关重要。当你想知道“我的交易成功了吗?”或者“某个智能合约是否触发了某个事件(Event)?”,你就是在这棵树里查找(或验证)这个“收据”。

    主要包含:

    • 交易 0 的结果:status: success, gasUsed: 21000, logs: [...]

    • 交易 1 的结果:status: failure, gasUsed: 50000, logs: []

  • 提款树 (Withdrawals Trie)这是“上海/Shapella”升级后新增的,专门用于处理从信标链(共识层)提款到执行层的操作,它为质押提款提供了可验证的记录。

    主要包含:

    • 提款 0:验证者 A 提取 X ETH 到地址 B。

    • 提款 1:验证者 C 提取 Y ETH 到地址 D。

logsBloom 日志布隆过滤器

在 ETH 的区块头还有一个logsBloom 字段,它使用布隆过滤器(Bloom Filter)来实现的,目的是为了做“快速索引”“摘要”。

在以太坊上,智能合约通过触发“事件”(Events/Logs)来与外界(DApp 前端、钱包)通信。例如,一个 ERC-20 代币合约在转账时会触发一个 Transfer 事件。假设你的钱包想显示你所有的 ERC-20 代币转账记录。它该如何找到这些记录?

如果没有 logsBloom ,那么钱包必须下载整条链(几 TB 的数据),然后遍历每一个区块里的每一笔交易每一条收据(Receipt),逐一检查其 logs 字段,看看是不是你想要的 Transfer 事件。这对于轻客户端(如手机钱包)或 DApp 前端来说是绝对不可能的。

logsBloom 巧妙的使用布隆过滤器(Bloom Filter)来构建区块中的交易触发的所有事件索引 log,当一个合约触发一个事件时,例如 Transfer(address indexed from, address indexed to, uint256 value),以太坊会把触发事件的合约地址所有被 indexed (索引) 标记的参数(比如 fromto 的地址)“添加”到这个布隆过滤器中。

布隆过滤器(Bloom Filter)是一种概率型数据结构,它非常节省空间,专门用来回答一个问题:“某个东西可能 在这个集合里吗?

它的回答只有两种:

  1. “绝对没有” (False): 如果它说“没有”,那这个东西 100% 不在集合里。
  2. “可能有” (True): 如果它说“有”,那这个东西有很大概率在集合里。(注意:它有很低的概率是“假阳性”,即它以为有,但其实没有)。

对 布隆过滤器(Bloom Filter)感兴趣的,可以去看我这篇文章:Go语言实现布谷鸟过滤器

那么有了这个 Log 我们就可以:

  1. 你的钱包想查找所有“发送到你地址 0xABC...”的 Transfer 事件。
  2. 不需要下载整个区块。它只需要下载区块头(非常小)。
  3. 它检查区块头里的 logsBloom 字段。
  4. 它向这个 logsBloom 提问:“你这里面可能包含 0xABC... 这个地址吗?”

logsBloom 回答 “绝对没有”那么钱包100% 确定这个区块里没有任何一笔交易触发了与 0xABC... 相关的事件。logsBloom 回答 “可能有”钱包才会去下载这个区块的完整数据,来精确找到它要的 Transfer 事件。

工作量证明 & 权益证明

为什么要有共识机制

我们先来说一下为什么要有PoW (工作量证明)PoS (权益证明) 这种共识机制,它们都是为了解决一个在计算机科学中极其古老且棘手的问题,尤其是在一个“去中心化”和“无需信任”的环境中,用来确保即使网络中充满了互不信任的陌生人(甚至有坏人),整个系统也能安全、一致地运行。

它们具体解决了以下三个关键问题

  1. 防止"女巫攻击" (Sybil Attack) —— 谁有资格记账?

    在一个开放的网络中,一个坏人几乎可以零成本地创建一百万个“假身份”(节点)。如果“记账权”是靠“一人一票”来决定的,那么这个坏人就能轻易地用他的“百万大军”投票控制整个网络。

    因为在 web2 中,是有一个去中心化的节点来控制的,所以一般是通过认证与授权 (Authentication & Authorization)来实现的,但是 web3 中,是去中心化的,所以需要设计这样的共识机制,用它增加记账的门槛,防止坏人可以低成本的记账,对网络产生影响。

  2. 防止"双花" (Double-Spending) —— 如何确保账本不可篡改?

    "双花"是数字货币的“原罪”。坏人张三有 10 ETH,他先发一笔交易给李四,同时(或之后)又发一笔交易把同样的 10 ETH 发给王五。网络必须决定哪一笔交易是“唯一真实”的。

    在 web2 中,是通过 一个中心化的数据库 (Single Source of Truth) 来实现的,在web3中是一个分布式的账本,我如何确保所有人都同意“张三的 10 ETH 是先给了李四,而不是先给了王五”?

    那么web3中就可以共识机制就可以设计一些有成本的操作,让“作恶成本”提高,来确保账本的唯一性和不可篡改性。

  3. 激励机制 (Incentives) —— 为什么有人愿意来记账?

    既然保护网络这么昂贵(要买矿机或锁定 ETH),为什么会有人愿意做这件事?

    在 Web2 中,不需要通过激励“陌生人”来进行记账,银行只需要商业模式 (Business Model) 和 雇佣 (Salary)机制来保障,不需要别人来记账。

    但在web3中需要奖励机制来鼓励诚实者来进行记账,诚实节点地遵循规则、打包区块、验证交易,系统就会奖励你新发行的代币(例如 ETH)和用户支付的交易费。这样ETH 越有价值,你作为奖励收到的 ETH 就越值钱,你也就越有动力去保护它。

所以这也是为什么在 web3 中需要PoW (工作量证明)PoS (权益证明) 这种共识机制。下面我们来看看这两种共识机制有什么区别。

PoW (工作量证明)其实就是需要矿工投入巨额的硬件成本电费(物理工作)来解题。第一个解出题的就拥有的记账权。但是 PoW 有个极大的问题就是它不环保,浪费了大量的电来做这个事情。

那么就有人提议,其实PoW为了能去挖矿是需要投入巨量的硬件成本电费,最后就是谁投入的钱多,谁就拥有这个记账权,既然如此,可以不可以直接点,直接用金钱来做抵押,那么这就是PoS (权益证明)基本理念,在 PoS 机制下,网络的安全不再依赖于消耗能源,而是依赖于经济激励惩罚

我们下面来详细看看 PoS 是怎么做的。

PoS (权益证明)

在 PoS 中验证者取代了 PoW 中的“矿工”。他们是运行特定软件的节点,负责处理数据、执行交易,并将它们打包成新的区块添加到链上。要成为一个验证者,你需要向一个特殊的智能合约中质押 32 个 ETH

质押就是将 32 ETH 锁定,这部分的资金会在惩罚的时候用到,一旦发现作恶,作恶者的一部分质押金(最多 32 ETH)将被销毁(永久消失),并且该验证者将被强制踢出网络。比如在同一个时隙提议两个不同的区块(试图分叉),抑或是提交自相矛盾的投票(例如试图支持两条不同的链),这些都是作恶的行为。

在 ETH 中,是按时间来组织打包区块的,每个 固定的 12 秒时间段被称为 Slot,理论上每个 Slot 都会产生一个新区块。

每个 Slot,系统会随机选择一个验证者作为“区块提议者”(Proposer), 32 个Slot 组成一个Epoch(约 6.4 分钟)。

ETH 的 PoS 架构分为两层:

  1. 共识层 (Consensus Layer, CL):这是 PoS 的“大脑”。它不处理交易,只负责协调所有验证者、随机抽签、分发选票、统计投票,并就区块的顺序和有效性达成共识。
  2. 执行层 (Execution Layer, EL):这是“引擎”。它负责执行智能合约、处理交易、更新我们之前讨论的“状态树”(State Trie) 和其他三棵树。

具体步骤:

  1. 在每个 12 秒的 Slot 开始时,共识层会从所有验证者中随机抽选一个验证者,作为这个 Slot 的“提议者”(Proposer)。

  2. 提议者打包区块,被选中的 proposer:

    1. 从“执行层”的交易池 (Mempool) 中抓取一批交易。
    2. 打包这些交易,创建一个新的区块
    3. 对这个新区块签名,并将其广播到整个网络。
  3. 共识层会为同一个 Slot 随机抽选一组(一个“委员会”/Committee),大约 100~几百个验证者组成的 Attestation 委员会(Committee)

    委员会的工作:

    1. 它们会收到“提议者”广播的新区块。
    2. 它们验证这个区块的有效性(签名是否正确?交易是否合法?)。
    3. 如果有效,它们会投出“赞成票”(Attestation)。
    4. 广播 attestation 给全网

    这些“赞成票”会汇集到共识层。

上面的讲述中,为了防止节点作恶,PoS 设计了一套安全机制。

如果诚实地参与提议和投票,验证者会获得两种奖励:

  1. 共识层奖励: 少量新发行的 ETH,作为维护网络安全的“工资”。
  2. 执行层奖励: 用户支付的“小费”(Priority Fees)。

如果节点作恶,那么就会执行相应的惩罚,这里的惩罚分为几种:

  1. 轻微惩罚 (Inactivity Leak):节点掉线了(例如停电、断网),没能及时投票,这样会损失掉本应获得的小额奖励
  2. 严厉惩罚(Slashing):比如双重提议 (Double Proposing),在同一个 Slot 提议了两个不同的区块;双重投票 (Double Voting),同一个 Epoch 里投票给了两个竞争的区块(试图制造分叉)。这样做会将质押的 ETH 进行一定的销毁,并强制踢出验证者队列,永久失去参与共识的资格。

LMD-GHOST机制

还有就是真的就是有作恶者进行了分叉选择 (Fork-Choice),提议了两个不同的区块,或者网络延迟导致出现了两个竞争的区块(分叉),怎么办?

在 ETH 中是通过 LMD-GHOST 机制来保障。节点根据“所有验证者最近一次投票(attestation)”来决定哪条分支最“重”,从而选出链头(head)。

当你的以太坊节点需要确定“主链的头部是哪个区块”时,它会执行以下操作:

  1. 从一个已“最终确定” (Finalized) 的区块开始作为 root,Finalized 的区块一般是上个 Epoch 锁定的。
  2. 查看它“观测到”的、来自所有验证者的“最新”投票。
  3. 使用“贪婪”算法,从 root 出发,在每一个分叉路口,都选择那条其子树累计获得了最多“质押权重”投票的路径。
  4. 一直走到这条“最重”路径的末端(叶子区块),这个叶子区块就是当前的主链头部(Head)。

Casper FFG机制(FFG)

Casper FFG 就是 ETH 的最终性 Finality 的机制,用来决定哪些区块被不可逆地锁定。

因为LMD-GHOST 选出的“头部”是可以改变的。如果网络延迟很严重,或者有攻击者在故意制造分叉,LMD-GHOST 选出的“最重链”可能会在 A 链和 B 链之间“摇摆不定”。这样用户没法确定这笔存款是不是100%到账了。

在 BTC 中我们知道它是通过“6 个区块确认”来确保交易的理论安全,而 FFG 是通过 Epoch 来确定。

ETH 在每个 Epoch 的第一个区块设立了Checkpoint (检查点),FFG 会通常跨越两个 Epoch(约 13 分钟)来实现最终确定。

举个例子,假设我们现在处于 Epoch 100

第一步:标记Justification (合理化)

  • 所有验证者(委员会)会一起投票,连接“上一个检查点”和“当前检查点”,即投票支持 C(99) -> C(100) 这条链;
  • 如果超过 2/3 的总质押 ETH 都投票给了这个连接;
  • C(100) 这个检查点区块被标记为 "Justified" (合理化),但是 Justified只是意味着“这个区块看起来非常棒,全网大部分人都同意它在主链上”,但它还不是最终的。

第二部:Finalization (最终确定)

  • 时间进入到了下一个 Epoch,Epoch 101
  • 验证者们再次投票,支持 C(100) -> C(101)
  • 如果 C(101) 也获得了超过 2/3 的投票,它自己也变成了 "Justified"
  • 此时,协议会回头看 C(101) 的“来源”——C(100),因为 C(100) 本身是 "Justified" 的,所以协议在这一刻C(100) 的状态升级为 "Finalized" (最终确定)

小结

所以我们可以从上面看的出来 ETH 的 PoS 机制依靠验证者(质押 32 ETH)而非矿工来保护网络。其安全不靠算力,靠押金:诚实有奖,作恶(如双重签名)则被罚没 (Slashing)

这套系统由两个协同工作的机制驱动:

  1. LMD-GHOST (选头部): 一个快速、灵活的规则,根据验证者的最新投票来选择“当前”的主链。
  2. Casper FFG (定最终): 一个缓慢、严谨的规则,通过“两步确认”将历史区块永久锁定(Finalized),使其不可逆转。

智能合约

简单来说:智能合约就是运行在以太坊区块链上的一段“自动执行的代码”。我们可以把它比喻成一台全自动贩卖机。

  • 输入:你投币(转账 ETH)并选择商品(调用函数)。
  • 逻辑:机器内部验证金额是否足够(代码逻辑判断)。
  • 输出:吐出饮料(分发 Token 或 NFT)并找零。
  • 特点:无需店员(去中心化),一旦设定无法随意更改价格(不可篡改)。

智能合约有这几个特点:

  1. 自动执行:一旦条件触发,就一定会执行,没人能阻止(连开发者本人也不行)。
  2. 不可篡改:部署到以太坊后,代码永远不能改(除非你一开始就写好升级机制)。
  3. 完全透明:全世界都能看到代码是什么(在Etherscan上点开任何合约都能看源码)。
  4. 去中心化:不靠任何公司或服务器,运行在全球几万台节点上,只要以太坊活着,它就活着。
  5. 用Gas付费:每次调用智能合约都要花一点ETH作为“燃料费”(Gas),防止有人写无限循环攻击网络。

一个最简单的“银行”合约

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

contract SimpleBank {
    // 1. 状态变量 (State Variable)
    // 这些数据会永久写入区块链,类似于数据库中的表
    mapping(address => uint256) public balances;

    // 2. 事件 (Event)
    // 类似于日志系统,用于前端监听
    event Deposit(address indexed user, uint256 amount);

    // 3. 函数:存款
    // payable 关键字表示该函数可以接收 ETH
    function deposit() public payable {
        require(msg.value > 0, "Deposit amount must be greater than 0");

        // msg.sender 是调用者的地址
        balances[msg.sender] += msg.value;

        emit Deposit(msg.sender, msg.value);
    }

    // 4. 函数:提款
    function withdraw(uint256 amount) public {
        require(balances[msg.sender] >= amount, "Insufficient balance");

        balances[msg.sender] -= amount;

        // 将 ETH 转回给用户
        payable(msg.sender).transfer(amount);
    }
}
  • mapping 就是 Key-Value 存储(类似于 Redis),address 是 Key,uint256 是 Value。

  • msg.sendermsg.value 是全局注入的上下文变量(Context),无法伪造。

  • require 类似于 Assert 或中间件校验,如果不通过,整个事务回滚。

原子性

需要注意的是,智能合约的每次执行具有原子性,比如像上面这个银行的例子,如果失败了,就整个操作进行回滚,不存在中间状态;如果所有步骤都成功,所有的状态变更(State Changes)会被一起写入区块,永久生效。其实这就有点像数据库的事务。

熟悉数据库的朋友我可以这么解释:

在 EVM(以太坊虚拟机)中,每一笔交易天然就是一个隐式的 START TRANSACTION ... COMMIT。你不需要显式地写 Commit,但任何未捕获的错误都会触发自动 Rollback。

跨合约组合(Composability)

原子性不单单局限于单个合约内部,而是可以跨越多个合约的调用链。

假设一笔交易的调用链是这样的:

用户 -> 合约 A -> 合约 B -> 合约 C

如果在 合约 C 的执行中出错了:

  1. 合约 C 的状态回滚。
  2. 合约 B 的状态回滚。
  3. 合约 A 的状态回滚。
  4. 用户发起的这笔交易被标记为“失败”

这里就有一个问题,执行失败会收 Gas 费吗?我们先看看什么是 Gas 费

Gas 费

Gas 费本质上等于:工作量(Gas Units)X 单价(Gas Price)。

Gas Units(工作量/计量单位)

EVM(以太坊虚拟机)执行的每一个操作码(Opcode)都有一个固定的 Gas 消耗值,操作越复杂,消耗越高。

比如下面这些操作:

计算操作(便宜)

  • ADD (加法): 3 Gas
  • MUL (乘法): 5 Gas
  • KECCAK256 (哈希计算): 30 Gas + 动态数据费用

存储操作(极贵)

  • SSTORE (写入/修改状态变量): 20,000 Gas (这是最贵的操作之一)
  • SLOAD (读取状态变量): 2,100 Gas
  • LOG (生成日志): 375 Gas

基础费用

  • 发起一笔最简单的转账交易(不调用合约):固定 21,000 Gas

Gas Price(单价/市场汇率)

这是由市场供需决定的变量。Gas Units 是“你需要多少升油”,Gas Price 就是“今天加油站一升油卖多少钱”。

Gas 的单位是 Gwei:

  • 1 ETH = 10^18 Wei
  • 1 Gwei = 10 ^9 Wei (即 0.000000001 ETH)

动态定价

  • 网络拥堵时:大家都在排队提交交易,为了插队,你必须出高价(比如 100 Gwei)。这就像滴滴打车的“高峰溢价”。
  • 网络空闲时:价格下降(比如 10 Gwei)。

最终计算公式

现在的以太坊(EIP-1559 升级后)计费变得稍微复杂了一点点,分为两部分:

Tx Fee = Gas Used X (Base Fee + Priority Fee)
  1. Base Fee (基础费)
    • 系统自动定。根据上一个区块满了没,自动调整。
    • 这部分钱会被“销毁”(Burned),也就是说这部分 ETH 直接从流通总量中消失了(通缩机制)。
  2. Priority Fee (小费/优先费)
    • 你给矿工/验证者的红包
    • 如果你想让交易快点确认,就多给点小费,验证者会优先打包小费高的交易。

举个具体的例子:

假设你要在这个拥堵的周五晚上,调用一个合约函数 buyItem()

A. 代码层面(Gas Units) EVM 跑完你的代码,发现你做了一次加法,写了一次数据库,总共消耗了 50,000 Gas

B. 市场层面(Gas Price) 当前的 Base Fee 是 50 Gwei,为了快点成交,你给了 2 Gwei 的小费。 总单价 = 52 Gwei。

C. 你的账单

花费 = 50000(Units)X 52(Gwei) = 2,600,000 Gwei

换算成 ETH 就是 0.0026 ETH。 假设 ETH 现价 $3000,这笔操作就要花你 $7.8 美金

操作失败会收 gas 费吗?

两种失败场景的区别:

在 EVM 中,交易失败主要分两种情况,扣费逻辑略有不同:

  • 场景 A:Gas 耗尽(Out of Gas)

    Gas Limit 是预算上限,假如设置了100,000Gas ,意思是“我这笔交易最多允许烧掉 100,000 Gas,再多我就不付了”。

    如果代码跑到 100,000 还没跑完(Out of Gas),EVM 强制停机,这 100,000 的钱全部扣掉不退,且交易回滚。

  • 场景 B:逻辑错误(Revert / Require 失败)

    比如转账余额不足、权限不够。比如代码运行到第 10 行,触发 require(false)。这样只扣除前 10 行代码消耗的 Gas。剩余未使用的 Gas(即 Gas Limit - Gas Used)会退回到你的钱包。

为什么要这么设计?

对于去中心化网络,这是为了防止 DDoS 攻击(拒绝服务攻击)。 如果失败不收费,黑客可以写一个无限循环的恶意合约:

while(true) { i++; }

然后向网络发送几百万笔交易来调用它。如果不收费,全网节点的 CPU 就会被免费占用,导致网络瘫痪。

小结

我们可以看出ETH的智能合约,把区块链从“账本”升级为了“通用计算平台”。没有智能合约,区块链就只能炒币(Store of Value);有了智能合约,区块链才有了应用层(Application Layer),多了更多的活力和玩法。

比如:

  • Uniswap(去中心化交易所 – AMM),它没有传统股票交易所的“订单薄”(Order Book),没有挂单和吃单的概念;
  • Aave / Compound (去中心化借贷),资金池模式。存款人把钱扔进池子拿利息,借款人抵押资产从池子借钱付利息;
  • MakerDAO (去中心化稳定币 – DAI),相当于是以太坊上的“美联储”,通过合约抵押的方式,生成一个永远锚定 1 美元的币(DAI);

还有很多有意思的玩法,我这里就不一一列举了,如果要深入学习智能合约的话,可以看这几个教程:

https://cryptozombies.io/ 通过建一个“僵尸养成”区块链游戏,一步步教你写Solidity智能合约。

https://docs.soliditylang.org/ Solidity官方英文文档

https://www.wtf.academy/zh/course/solidity101 WTF Solidity极简教程

如何在ETH链上发币?

ETH 社区定义了 ERC-20 标准的 API 接口规范,任何一个智能合约,只要实现了这套规定的 API(方法和事件),它就是一个 ERC-20 代币。

我们可以把代币想象成存储在一个巨大的 Map 里面,比如这样的一个结构:

// 这是一个简化的核心存储结构
contract ERC20 {
    // 1. 账本:记录 "地址 -> 余额"
    mapping(address => uint256) private _balances;

    // 2. 授权表:记录 "我 -> 授权给谁 -> 多少钱"
    mapping(address => mapping(address => uint256)) private _allowances;

    // 3. 代币总供应量
    uint256 private _totalSupply;
}

当你发起一笔转账(例如 A 转给 B 100个币)时,EVM(以太坊虚拟机)到底做了什么?

  1. 交易发起:用户 A 向该代币合约地址发起一笔调用 transfer(B, 100) 的交易。
  2. 余额检查:EVM 检查 _balances[A] 是否大于等于 100。
  3. 数据重写
    • _balances[A] = _balances[A] - 100
    • _balances[B] = _balances[B] + 100
  4. 持久化:这些修改后的数据被写入以太坊的状态树(Merkle Patricia Trie),并打包进新的区块中,永久不可篡改。

数据结构

image-20251125210438372

我这边再引用一下上面的图,合约其实在 ETH 里面也是一个账户对象,当你通过地址找到这个账户(比如那个 ERC-20 代币合约地址)时,你会得到一个包含四个字段的结构体:

Nonce: 交易计数器。

Balance: 这里存的是 ETH 的余额(比如 0.5 ETH),不是代币余额

CodeHash: 智能合约的代码哈希(如果是普通账户则为空)。

StorageRoot (存储根): 这是一个哈希值,它指向了另一棵 MPT 树的根节点。这棵树专门属于这个合约,用来存储它所有的变量数据。

StorageRoot (存储根)

这棵树本质上是一个巨大的、持久化的 Key-Value 映射,其实也是一颗 MPT 树。

  • Key: 32 字节(256位)的存储槽位置 (Slot Index)。
  • Value: 32 字节(256位)的数据内容。

比如这样一个代币合约:

contract MyToken {
    // Slot 0: 假如这里有个 owner 变量
    address owner; 

    // Slot 1: 假如这里是总供应量
    uint256 totalSupply; 

    // Slot 2: 这里就是代币余额的 Mapping
    mapping(address => uint256) private _balances;
}

当 EVM 运行到 _balances 时,它并不会把整个 Mapping 存在 Slot 2 里面(因为 Mapping 大小是不确定的)。 它是通过哈希算法计算出具体的存储位置。

如果你想查 0xUserA 这个人的余额,数据存储在 Storage Trie 中的 Key 是这样算出来的:

image-20251126145250636

  1. 0xUserA: 用户的钱包地址(补齐到 32 字节)。
  2. SlotIndexOfMapping: _balances 变量在代码中声明的位置(假设是 Slot 2)。
  3. keccak256: 对这两个拼接后的数据进行哈希运算。

运算得出的结果(一个乱码一样的哈希值),就是该用户余额在底层的物理存储地址。 而这个位置对应的 Value,就是 uint256 类型的余额数值。

所以如果要查询代币的值,整个查找链条是这样的:

  1. Block Header -> 拿到 StateRoot

  2. World State Trie -> 用 TokenContractAddress 查找 -> 拿到 Account Object

  3. Account Object -> 拿到 StorageRoot (进入该合约的私有数据库)。

  4. Storage Trie -> 计算 keccak256(UserAddress + SlotIndex) 作为 Key -> 拿到余额数据

北大《区块链技术与应用》——ETH篇最先出现在luozhiyun`s Blog

泡温泉&amp;跑步(别府篇)

2025-11-02 20:37:17

最近有点时间,想找个地方休息一下,本来只是想去熊本看看高达的,别府只是顺路去一下,没想到这个小地方还是挺惊艳到。

别府有着很多温泉资源,拥有近3,000个温泉源头,温泉涌出量位居日本第一,在全球也仅次于美国的黄石国家公园。

温泉水多到什么程度呢,别府站旁边就有个池子可以用温泉水洗手,没错,就是下面这个雕像的右边,顺带一提,这个雕像的衣服会经常换,各位如果也来别府,看看他会穿什么。

image-20251102201329540

走在路上你甚至可以在街上的下水道里面看到有冒着白雾,也就是说这些下水道的水都是温泉水哦。

image-20251102201710144

泡温泉&跑步

在别府有很多温泉,大都集中在铁轮温泉区,其中最出名的就是这个温泉,葫芦温泉,是一家百年的老店。

image-20251102201933253

这个温泉固然是很好的,但是我不只是想讲这个温泉体验怎样。在泡温泉的时候,我就想泡温泉的时候就和跑步好像:

  • 不能玩手机;
  • 身体在承受一定的痛苦;

不能玩手机代表你必须要聚焦于自我的思绪当中,不受外界的信息干扰,表示你有更多的精力让你的思想放空,可以想到不一样的事情。很多时候,我都是在跑步的时候,放下手机的时候突然想起要做某件事情,然后去做,这篇文章也是一样,在泡温泉的时候,想起可以去写一篇这样的文章。

身体在承受一定的痛苦意味着身体会时不时提醒你有多久没关注过当下的生活了,你在吃什么,身边在发生什么事,经过了什么样的景色,遇到了什么样的人,似乎我们都错过了好多。但是泡温泉和跑步的时候,身体的痛苦会把你的思绪拉回来,让你记住当下的生活。

比如今天我泡温泉就记住了在一个池子里面有个老人长的挺帅的,可能有六七十岁了,戴着眼镜,有点像电影里面的老人,相信他穿着打扮一下,肯定气质不凡。

在露天温泉里有几个年轻人在我左前方一直在讲话,让我想起,要是国内也能这么方便的可以泡温泉就好了,一般我们都喜欢边吃饭边聊天,其实我们也可以把场景换一下,边泡温泉,大家赤诚相见,边泡温泉边聊天,其实也是不错的。

泡温泉和跑步有一点是不一样的,跑步都是越快越好,大家都在追求速度,但是温泉不一样,强制大家慢下来。在日本泡温泉的时候,会有告示牌告诉你,别这么急着进去泡,先慢慢洗一下身子。洗完身子之后,要缓步走到池子里,因为都是水,急的话就容易摔倒。进到池子的时候,也是要慢慢进去,因为水温很烫,身体一下钻进去肯定会把你烫出来。

我在国内生活很多年,几乎所有人都在说快一点,快一点。要快点学,赶紧考个好大学;要快点工作,好赚多点钱养家;要快点结婚生子,好繁衍子嗣;要快点工作完,好完成当下的kpi。那么什么时候慢下来呢?是必须要这么快吗?

说会到葫芦温泉,这里的水煮蛋,应该是我在日本看过最便宜的水煮蛋,只要80日元。

image-20251102204852686

在别府这里还有一个温泉,别府鉄輪蒸し湯,也是一家百年的老店,体验也还不错。

image-20251102205206851

这个温泉的特点仪式感很重,首先会让你进去先用温泉水淋一下,对,就是单纯的淋一下,然后穿上浴衣之后进入到蒸汽房里面,躺在草堆上,用药草蒸10分钟,到8分钟的时候门外会有人问一下你是否要提前出去,出来之后是感觉确实不同,很舒服。蒸完之后把浴衣换下之后就清洗一下身子开始泡温泉了。

汗蒸这个东西,其实很多温泉店都会有,但是这家温泉会强制顾客一定要去汗蒸一下,确实不太一样。

日本其实是一个很重仪式感的地方,它深刻地根植于日本文化的各个层面。比如泡温泉的时候,进玄关的时候会让顾客脱鞋,让顾客穿他们的浴衣,礼物本身可能不贵重,但包装一定要精美,店里面的店员一定会对顾客鞠躬问好等等。

其实我是一个不怎么喜欢仪式感的人,比如我一般在逢年过节的时候不会特意的去买各种节日食品,月饼,汤圆之类的,过生日也不喜欢搞什么生日聚会,不会买蛋糕,也不会去买礼物。

但是我的观念最近在慢慢的改变,我觉得仪式感可能没什么不好。生活本质上是充满不确定性和混乱的。仪式感通过固定的程序和可预测的步骤,为我们创造了一个“可控”的心理空间,其实是一种对抗不确定性的方式。并且仪式感可以将平凡的日常行为转变为特殊且有意义的时刻,其实也蛮有意思。

比如同样是吃饭,用你最喜欢的餐具、摆放整齐、关掉电视专注地吃,这顿饭的体验和价值感就远高于边看手机边草草了事。我们大部分时间都处在“自动驾驶”模式,脑子里想着过去或未来,想着各种事情。而仪式迫使你将注意力拉回到“此时此刻”的身体和感官上,这也是仪式的意义。

喜欢在干净的路上逛街

在日本比起大城市,其实我更喜欢乡下,因为没什么人,并且很干净。有时候看着这种没人的干净街道,就想一直逛下去,即使没什么好看的,也可以安安静静地走一天。

image-20251102212001736

11月了草还挺绿。

image-20251102212522976

我还是很喜欢海边散步的。

image-20251102212535038

晚霞

image-20251102212650725

写于 2025年11月2日晚,别府三日游明早就要走咯。

泡温泉&跑步(别府篇)最先出现在luozhiyun`s Blog

北大《区块链技术与应用》——BTC篇

2025-10-30 23:08:27

课程地址:https://www.bilibili.com/video/BV1Vt411X7JF?spm_id_from=333.788.videopod.episodes&vd_source=f482469b15d60c5c26eb4833c6698cd5&p=2

什么是加密货币

加密货币(Cryptocurrency)是一种运用密码学原理来确保交易安全并控制新单位创造的数字交易介质 。

根据 Jan Lansky 所述,加密货币是满足六个条件的系统:

  1. 去中心化:该系统无需中心机构,也就是不需要央行,靠共识维持。
  2. 所有权记录:系统能够清晰地记录每一单位加密货币及其当前的所有权归属。
  3. 发行机制:该系统定义能否产生新的加密货币。如果可以,则系统需定义新币的来源,并定义如何确定这些新币的所有者。
  4. 密码学所有权:对加密货币的所有权只能通过密码学手段(即私钥)来证明和行使。
  5. 所有权转移:该系统允许通过交易来改变加密货币的所有权。交易仅可从能证明加密货币当前所有权的实体发布。
  6. 双重支付防范:如果同一时间产生了两个改变相同加密货币所有权的指令,该系统最多只能执行其中一个。

这些条件共同定义了一种革命性的资产形式,其价值和安全性不依赖于任何单一机构的信用背书。有些人简单的将它归结为“数字化”,其实是不对的,因为支付宝、信用卡支付等都是数字话的,但是这些传统数字金融系统的核心是建立在对中心化中介机构(如银行、支付网关)的“信任”之上,由它们来维护账本、验证交易的合法性 。加密货币的根本性突破在于,它通过密码学、分布式共识等一系列技术手段,构建了一个“去信任化”(Trustless)的系统。

区块链的核心数据结构

所谓区块链,其实是由一系列按时间顺序连接的数据单元构成的,这些单元被称为“区块”(Block)。每一个区块包含以下内容:

  • 每个区块的交易数据;
  • 指向前一个区块的加密引用;

链条的起点是一个特殊的区块,被称为“创世区块”(Genesis Block),因此没有指向“前一个区块”的引用。

那么在实现上,区块是如何保存交易数据和引用的呢?数据结构是怎样的?每个区块在数据结构上,包含了区块头(Block Header)和区块体(Block Body):

  • 区块头:每个区块起始处的一个紧凑的、固定大小的部分。在btc协议中,其大小为80字节 。头中不含交易数据,仅包含了元数据(metadata)。元数据包括指向前一个区块的链接、时间戳以及对区块体内所有交易数据的加密摘要。
  • 区块体:这是一个可变大小的部分,其主要内容是该区块所包含的经过验证的、详细的交易记录列表 。

区块头

我们以btc为例,在btc协议中,区块头大小为80字节 。根据功能,这80字节可以被划分为六个独立的字段,共同构成了区块的元数据 。

  • 版本(Version) – 4字节

  • 前一区块哈希(Previous Block Hash) – 32字节。这是区块链数据结构中最关键的连接元素,它就是哈希指针。该字段存储的是其父区块(即链上的前一个区块)的区块头的SHA256(SHA256())双重哈希值 。通过这个字段,每个区块都牢固地指向其前驱,从而将独立的区块编织成一条不可分割的、按时间顺序排列的链。

    image-20250615162240044

  • Merkle Root – 32字节。btc 中使用的 merkle tree 的形式存储了区块体内所有交易,所以在头里面,还存储了区块中 merkle tree 的头节点,也就是 merkle root;

  • 时间戳(Timestamp) – 4字节,记录了该区块被矿工创建的大致时间。

  • 难度目标/比特(Difficulty Target / Bits) – 4字节。表示了当前区块挖矿的难度目标 ,也就是用来挖矿的。

  • 随机数(Nonce) – 4字节。这是一个由矿工在挖矿过程中不断改变的计数器 。矿工将版本、前一区块哈希、默克尔根、时间戳、难度目标和这个随机数拼接在一起,形成80字节的区块头,然后对其进行哈希计算。如果结果不满足难度目标,矿工就将随机数加一,然后再次尝试。这个暴力枚举的过程就是工作量证明的核心。

区块体

区块体则承载了该区块内所有经过验证的交易信息,所以可以把它理解为账本。以btc为例,它的区块体的结构由两部分构成:

  • 交易计数器 (Transaction Counter):记录该区块中包含的交易总数。
  • 交易列表 (Transactions):连续存放的、该区块内所有交易的原始数据。

那么如何记录一笔交易呢?比如,我们现实生活中进行转账,A 要给 B 转账,那么对于这笔记录首先我们需要知道这个币从哪里来的,这个叫做输入(Inputs);然后需要知道这个币是转给了谁,这个叫做输出(Outputs)。在转账的过程中,由于互联网上是基于互不信任的原则,所以这笔转账的过程还需要密钥加密,这叫数字签名(Digital Signatures),数字签名由资金所有者使用其私钥生成。

A转了5个币给B,给了 5个币给 C,这个过程中不停的交易,形成的这个链就是账本。比如下图,可以看成是交易账本的简化形式。

image-20250615171338943

那么交易账本的形式有了,那么如何构建安全的交易呢?首先,我们要了解一下什么是签名,上面我们也说了,在交易的过程中,A 转帐给 B,A 需要给这笔转账用 A 的私钥加密,这其实就是签名。

整个签名,其实就是非对称加密的过程,在btc钱包中,公钥相当于银行账号,私钥相当于银行密码。比如说A 要给 B 转 1 个 BTC,当 A 发起这笔交易时,他的钱包软件会做以下事情:

  1. 首先要回答 A 的“钱包”里有什么?

    假设 A 的钱包里并不是一个写着“我有 1.2 BTC”的数字。实际上,他的钱包知道他拥有两笔“未花费的钱”(Unspent Transaction Output,简称 UTXO),比如:

    • UTXO-1:价值 0.5 BTC(来自之前别人付给 A 的一笔钱)
    • UTXO-2:价值 0.7 BTC(来自更早之前别人付给 A 的另一笔钱)
  2. A 要给 B 转 1 个 BTC

    当 A 发起这笔交易时,他的钱包软件会做以下事情:

    • 输入 (Input):交易的“输入”必须明确指出它要花费哪几笔 UTXO。在这个例子里,钱包会选择 UTXO-1 (0.5 BTC) 和 UTXO-2 (0.7 BTC) 作为输入。总输入金额为 1.2 BTC。
    • 指定输出 (Output)
      • 输出1:向 B 的地址支付 1 BTC。这会为 B 创建一个价值 1 BTC 的新 UTXO。
      • 输出2 (找零):将多余的 0.2 BTC(1.2 – 1.0 = 0.2)转回给 A 自己的一个新地址。这会为 A 自己创建一个价值 0.2 BTC 的新 UTXO。
    • 签名:A 用自己的私钥对这笔完整的交易(包括输入和输出)进行数字签名。
  3. 最后就是验证这笔交易如何验证,在 BTC 中整笔交易要达成共识,入链才算完成。

    这笔交易被广播到btc网络后,每一个收到它的节点(矿工)都会进行严格的验证:

    • 验证签名:首先,用 A 的公钥验证交易签名是否有效。
    • 验证资金来源(最关键的一步!):节点会追溯整个区块链历史,去检查 A 在交易输入中声称要花费的 UTXO-1 和 UTXO-2 是否真的存在,并且是否真的是“未花费”的状态。
    • 检查结果
      • 如果节点在过去的区块里找到了这两笔 UTXO,确认它们属于 A,并且之前从未被花掉过,那么节点就确认 A 确实拥有这笔钱。
      • 如果 A 试图花费一个不存在的、或者已经被花掉的 UTXO(这就是“双花攻击”),全网的节点在查账时会立刻发现这个输入是无效的,从而拒绝这笔交易。
    • 一旦验证通过,矿工就会把这笔交易打包进一个新的区块。

好了,到这里,一笔交易内容有什么,以及如何保证安全已经说明了,那么如何在不下载全部数据的情况下,高效地验证某一个“小数据”是否属于这个“大数据集合”?就比如,我的手机钱包,如果验证 1 笔交易,而不用下载整个区块?

这就要提到 Merkle Tree,它是交易列表构成的一个树形结构,Merkle Tree 的叶子节点存的是每一笔交易的哈希值(Transaction Hash,也叫 TXID),可以看成是下图这样的结构:

image-20250615163517554

merkle tree 本质上是一棵哈希二叉树

  • 树的叶子节点是原始数据块(在区块链中就是一笔笔的交易)的哈希值。

  • 树的非叶子节点(树枝和树干)是它下面两个子节点哈希值拼接后再计算出的哈希值。

  • 这个过程不断重复,层层向上,直到只剩下一个最终的、位于最顶端的节点,这个节点被称为 merkle root。

merkle tree被发明出来主要有两个目的:

  1. 保证数据完整性(防篡改)

    merkle root 是对区块内所有交易的最终摘要。任何一笔交易哪怕被改动一个字节,其对应的叶子哈希就会改变,这个改变会像多米诺骨牌一样,层层向上传导,最终导致计算出的merkle root 完全不同。

  2. 极高的验证效率(轻量级验证)

    它允许在不下载整个区块数据的情况下,就能快速验证某笔交易是否存在于该区块中。

    比如手机钱包知道自己的交易哈希 H3,钱包从网络上下载了该区块的区块头(只有80字节,非常小),并从中获取了正确的merkle root ,那么对于 H3来说,它的验证路径是 H3 -> H34 -> Merkle Root。也就是只需要,它的直接兄弟 H4,它的上一层节点的兄弟 H12,然后就可以通过计算 hash 进行对比验证,这个过程就叫Merkle Proof。

    整个过程,手机钱包只需要下载几十字节的区块头和几十字节的 Merkle Proof,就能完成验证,而无需下载整个区块(可能好几MB)的所有交易数据,极大得节省了资源。

所以通过 merkle tree 就可以实现高效的“存在性证明”(Merkle Proof)以及保证数据完整性(防篡改)。

Hash 在区块链的作用?

其中在区块链中使用 Hash 算法有其关键的作用:

  1. collision resistance:在btc中使用的是SHA256(SHA256())双重哈希值,几乎不可能出现hash碰撞,因为如果可以轻易找到“碰撞”(两个不同输入得到相同输出),那么恶意攻击者就可能用一笔伪造的交易来替代合法的交易,从而破坏整个系统的信任;

  2. Hiding:由于算法的不可逆,所以无法由hash值推导出原值。这个特性对于保护数据隐私至关重要。在密码学中,这也被称为抗原像性 (Pre-image Resistance)。;

  3. puzzle friendly:如果想要找到某个特定的hash值对应的输入是什么,只能挨个去尝试,没有其他任何途径可以找到符合条件的hash值。

区块链中,由于下一个的指针是指向前一个,如果某个块的hash发生了改变,那么后续的也要改变,也就是牵一发而动全身,比如一个Merkle tree,叶子节点变了,其他节点也要变,因为其他节点是根据叶子节点算出来的。

                        [ Merkle Root ]
                              /     \
                             /       \
                    [ Hash_ABCD ]   [ Hash_EFGH ]
                       /     \         /     \
                      /       \       /       \
                [ Hash_AB ] [ Hash_CD ] [ Hash_EF ] [ Hash_GH ]
                   /   \       /   \       /   \       /   \
                 H(A) H(B)   H(C) H(D)   H(E) H(F)   H(G) H(H)

所以btc 中某个本地节点可以只保存最近的某些节点,如果需要前面的其他的节点可以问别人要,并且可以通过hash计算的方式来保证别人给的区块一定是正确的。就比如上图,只有几个节点,但是可以通过后面的节点计算别人给过来的前面的节点是否正确。

举例:

  1. 全节点发给你三样东西:
  • TX_C(交易 C 本身)
  • H(D) (你的“兄弟”哈希)
  • Hash_AB (你“叔叔”辈的哈希)
  • Hash_EFGH (你“伯父”辈的哈希)
  1. 轻钱包开始自己计算:
  • 第 1 步: 先把 TX_C 自己哈希一次,得到 H(C)
  • 第 2 步: 用自己算出的 H(C) 和全节点给你的 H(D) 组合起来哈希:
    • H( H(C) + H(D) ) 算出了 Hash_CD
  • 第 3 步: 用全节点给你的 Hash_AB 和上一步算出的 Hash_CD 组合起来哈希:
    • H( Hash_AB + Hash_CD ) 算出了 Hash_ABCD
  • 第 4 步: 用上一步算出的 Hash_ABCD 和全节点给你的 Hash_EFGH 组合起来哈希:
    • H( Hash_ABCD + Hash_EFGH ) 算出了一个“最终的 Root”
  1. 算出来的“最终的 Root”从区块头里拿到的那个“标准答案 Merkle Root”进行对比。

因为哈希的collision resistance特性。如果“别人”的 TX_C 是假的,或者 H(D)Hash_AB 中任何一个是假的,最终算出来的 Root 都不可能与“标准答案”一致。

分布式共识 distributed consensus

一个交易要被认可要取得分布式共识。那么什么什么是分布式共识?

想象一下,一群好朋友(比如 100 个人)共同记一本账本,记录着大家之间谁欠谁钱。他们没有一个中心记账员(比如银行),而是每个人手上都有一本一模一样的账本。

当其中一位朋友 A 要转 100 元给朋友 B 时,他会向所有人大喊:“我要从我的账上转 100 元给 B!”。

这时候问题来了:

  • 怎么确保 A 真的有这 100 元?
  • 怎么确保 A 没有同时跟别人说“我要把这 100 元转给 C”?(也就是所谓的“双花攻击”)
  • 最重要的是,在没有中心决策者的情况下,如何让这 100 个人全都同意在自己的账本上写下“A 转给 B 100 元”这同一笔记录,并且确保之后没有人可以反悔或篡改?

分布式共识 就是为了解决这个问题而设计的一套规则和方法。它的目标是:让一个分布式系统中的所有参与者(节点),在没有中央指挥的情况下,最终能够对某个状态或数值达成一致的协议。

如何达成分布式共识

上面我们也提到了,如果想要 A 转给 B 1 个 BTC,在 BTC 中整笔交易要达成共识,入链才算完成。这个共识算法就是工作量证明(Proof of Work, PoW)。

在 A 转 1 个 BTC 给 B 的时候,会用私钥对一笔交易进行数字签名,然后钱包会将这笔签好名的交易广播到整个区块链网络中,附近的节点会接收到这笔交易。

之后:

  1. 节点验证与传播

    • 初步验证:

      接收到交易的节点(我们称之为“矿工节点”)会立即进行验证,首先用A的公钥检查数字签名是否正确,再来就是追溯A的交易历史,确保您确实拥有足够的资金来支付这笔交易;

    • 验证通过后,这笔交易会被放入该节点的“内存池(Mempool)”中,这是一个等待被打包的交易的临时集合;

    • 该节点会将这笔验证过的交易继续传播给与它相连的其他节点,直到这笔交易遍布全网。

  2. 竞争记账权(挖矿)

    • 所有矿工节点会从自己的交易池中挑选一批交易,将选中的交易和上一个区块的哈希值等信息打包成一个“候选区块”
    • 矿工们开始进行疯狂的哈希计算,不断变换候选区块头中的一个随机数(Nonce),试图找到一个满足特定难度目标的哈希值(例如,开头有非常多个零);
  3. 达成共识与全网同步

    • 假设矿工 M 率先找到了正确的哈希值,他立刻向全网广播他创建的、包含您交易的新区块;
    • 网络上其他矿工收到这个新区块后,会停止自己的计算,并立即验证这个区块的有效性(包括验证其中的每一笔交易和那个“幸运哈希值”是否符合规则);
    • 验证非常快速。一旦确认无误,其他节点就会承认这个新区块是合法的,并将其添加到自己的区块链副本的末端。
    • 这就代表,全网对“这个新区块以及其中包含的所有交易是有效的”这件事达成了共识

由于网络问题产生分叉怎么办?比如即同时有两个矿工挖出区块,这个时候为了确保交易不可逆转,通常需要等待更多的区块在此基础上继续生成。

一般来说,在btc网络中,等待 6 个区块确认(大约 1 小时)后,该笔交易就被认为是完全被承认且不可篡改的了。

为什么矿工要帮忙做工作量证明

上面的工作量证明看起来实际上需要很大的计算量,需要很多计算机的算力,所以矿工做这些事情也会获得相应的报酬去激励他们继续保护和运行整个区块链网络。矿工主要有两部分收益:

  1. 区块奖励(Coinbase Reward)

    这是系统凭空创造出来、作为对矿工维护网络安全奖励的新币。这部分奖励是btc(或其他加密货币)通货膨胀的主要来源。对于btc来说这个奖励的数额是协议预先规定好的,并且会定期“减半”(Halving)。例如,btc最初每个区块奖励50个btc,现在(2024年减半后)是3.125个btc;

  2. 交易手续费(Transaction Fees)

    这是该矿工从他打包的那个区块中,所有交易的发起者支付的手续费的总和。用户为了让自己的交易能被矿工尽快打包,会附加一笔手续费。矿工自然会优先选择手续费高的交易来打包。这部分收益的数额是不固定的,取决于当时网络的拥堵情况和用户愿意支付的费用。

所以矿工的总收益 = 区块奖励 + 该区块内所有交易的手续费总和。

但是在 btc 中随着时间的推移,区块奖励会越来越少,直到最终变为零(预计在2140年左右)。到那时,矿工维护网络的唯一动力就将完全来自于交易手续费。这个设计确保了即使在所有币都发行完毕后,依然有经济激励促使矿工继续保护和运行整个区块链网络。

挖矿

上面我们提到了,挖矿的过程其实就是改变block header 里面的 Nonce 字段,计算出一个有效的“哈希值”,计算出的哈希值必须小于或等于当前网络设定的“目标值” (Target)。

举个例子,假设目标是: 00000000000000000005a3f6d8a4c1d8d3f6a8b3c5d1e7f9a2b4c6d8e。那么,任何计算出来的哈希值,只要在数值上比上面这个小,就是有效的。比如:00000000000000000001b8d3c5d1e7f9a2b4c6d8e4a3f6d8a4c1d8 (这个值更小,所以是有效的)。

但是Nonce 只是一个 32 位的字段,目前来说矿机每秒可以执行数百亿亿次哈希运算(TH/s)。一台高端ASIC矿机(例如140 TH/s)可以在微秒级别的时间内遍历整个Nonce空间。

所以仅靠Nonce 是找不到对应难度目标(Target)的值,当然Timestamp也可以有一定的调整空间,但是比较有限,后面就演变成使用 ExtraNonce 来扩展搜索空间。ExtraNonce 指的是矿工放置并修改在铸币交易(Coinbase Transaction)的scriptSig字段(也被称为coinbase data)中的任意数据,它可以进行修改 。

scriptSig 字段通常是用来数字签名来证明所有权用的,但是Coinbase交易是凭空创造新币的,它不消耗任何已存在的UTXO。因此,其输入中的scriptSig字段无需包含任何解锁脚本或数字签名。根据btc协议,这部分空间可以由矿工自定义填充,长度限制在2到100字节之间。

那么挖矿算法的大致流程就会变成:

  1. 构建区块模板:
    • 从内存池(mempool)中选择交易,通常按费率(fee rate)高低排序。
    • 构建铸币交易,包含区块奖励、交易费,并设置一个初始的ExtraNonce值(如0)。
    • 基于此交易集合计算出hashMerkleRoot
    • 组装候选区块头,填入VersionhashPrevBlockhashMerkleRoot、当前的TimeBits
  2. 内层循环(Nonce迭代):
    • FOR nonce FROM 0 TO 2^32:
      • 在区块头中设置当前的nonce值。
      • 计算 hash = SHA256(SHA256(header))
      • IF hash <= Target:
      • 找到解。广播完整的区块。
      • 返回第一步,开始构建下一个区块的模板。
  3. 外层循环(ExtraNonce迭代):
    • IF 内层循环完成仍未找到解:
      • 在铸币交易中递增ExtraNonce的值。
      • 重新计算hashMerkleRoot
      • 更新区块头中的hashMerkleRoot字段。
      • (可选)如果时间变化足够大,更新Time字段。
      • 返回第二步,使用新的区块头重新开始Nonce的迭代.

并且为了控制 btc 的产量,在btc网络诞生之初,矿工每成功打包一个区块,可以获得 50 BTC 的奖励。并且btc的协议规定,每产生210,000个区块,区块奖励就会减少一半(减半)。由于比特币网络的目标是平均每10分钟产生一个区块,210,000个区块大致相当于4年的时间(10分钟×210,000≈4年)。

我们可以用一个等比数列求和的公式来表示这个过程:

总供应量 = 210000×(50+25+12.5+6.25+…) = 210000×50×(1+0.5+0.25+0.125+…)=210000×50×2=2100w

image-20250705234838859

由于区块奖励不断减半,奖励金额会变得越来越小。大约在第33次减半(约2140年左右)之后,区块奖励将变得微不足道(小于1聪,即比特币的最小单位)。届时,可以说几乎所有的比特币都已被挖出,矿工的收入将完全依赖于交易手续费。

这种通缩模型的设计,使得比特币具有了稀缺性,从而避免了像传统法定货币那样因无限增发而导致的通货膨胀问题。

为什么早期仅遍历Nonce就可以,现在却不行?

主要是因为btc它会自动的调整难度,早期的时候参与者少,算力低在只有几 MH/s 的算力下,要遍历完这43亿种可能性需要很长时间(几百秒甚至更久)。当时网络的目标是大约10分钟产生一个区块,因此,仅仅通过不断尝试和改变Nonce,就有非常大的概率能在这10分钟内找到一个符合条件的哈希值。

现在对于一台算力为 100 TH/s(1014 次哈希/秒)的现代ASIC矿机来说,遍历完43亿的Nonce仅需要0.000043 秒,如果还是这个难度,估计用不了几秒就要被挖光了。

btc的难度调整是其协议中最优雅的设计之一,它确保了无论全网算力如何变化,新区块的产生速度都能稳定在平均10分钟一个。

btc的难度目标(Target)是个以一个256位的数字。挖矿的本质就是找到一个区块头的哈希值,使其小于或等于这个目标值。目标值越低,挖矿越难。因为一个更低的目标值意味着哈希结果的开头必须有更多的“0”,符合条件的哈希值就越少,找到它所需要的计算次数就越多。

所以为了实现大约10分钟产生一个区块这个目标,btc会动态的调整难度,难度调整的具体机制如下:

  1. 比特币网络中的每个全节点都会每2016个区块自动进行一次难度调整。节点会计算生成这最近的2016个区块所花费时间,即 2016区块 × 10分钟/区块 = 14天;
  2. 节点会根据实际时间与期望时间的偏差来调整下一个周期的目标值,公式New Target = Old Target * (Actual Time / Expected Time)
    • 挖得太快了。如果Actual Time小于20160分钟(比如只用了12天),说明全网算力增强了。此时 (Actual Time / Expected Time) 这个比率小于1,New Target就会变小,从而增加挖矿难度
    • 挖得太慢了。如果Actual Time大于20160分钟(比如用了16天),说明全网算力下降了。此时这个比率大于1,New Target就会变大,从而降低挖矿难度
  3. 为了防止网络因算力剧烈波动而产生过大的难度变化,单次调整的幅度被限制在一个4倍的范围内。即,调整系数(Actual Time / Expected Time)最大不会超过4,最小不会低于1/4。

我们也可以看到下面图的难度曲线的设置,是越来越陡峭的,跟价格几乎是成正比:

image-20250720180550478

并且比特币矿机也不再是以前的 GPU 时代了,而是专门的专用矿卡进行挖矿。比特币矿机的演变是一场追求极致算力和能效比的“军备竞赛”:最初人们用个人电脑(CPU) 就能挖矿,很快被游戏显卡(GPU) 的高并行算力所取代;接着,更省电的FPGA(半定制芯片) 短暂出现,但最终被ASIC(专用定制芯片) 以绝对的算力和能效优势彻底统治,使挖矿从此进入了专业的工业化时代。

Progress-Free性质

Progress-Free 指的是“无记忆性”(Memoryless),也就是在任何给定时刻,矿工找到下一个区块的概率与他们过去已经付出的努力无关。

在btc挖矿中,矿工们不断地进行哈希运算,尝试找到一个小于当前网络目标难度的哈希值。每一次哈希运算都是一个独立的、随机的尝试。也就是说包含两个特性:

  • 独立的尝试: 每一次哈希运算都使用一个略有不同的输入值(通过改变一个称为“nonce”的随机数)。因此,前一次哈希运算的结果对后一次完全没有影响。就像抛硬币一样,无论您已经连续掷出了多少次反面,下一次掷出正面的概率永远是50%,和过去的努力无关。
  • 成功的偶然性: 能否找到有效的哈希值,完全取决于运气。

由于这两个特性,表示过去的努力不会累积,每时每刻都是一个全新的开始,确保了挖矿的公平性,即便是算力较低的矿工,理论上也有机会在任何时刻找到区块。

挖矿攻击

Boycott Attack

指的是网络中掌握显著比例算力(或权益)的参与者(通常是大型矿池或PoS验证者)联合起来,故意“抵制”或“排斥”网络中的某些特定元素。

在这种攻击中最常见的就是一个或多个拥有大量算力的矿池故意拒绝将某些合法的交易打包到他们挖出的区块中。比如,某个矿池为了遵守其所在国的法规(如美国的 OFAC 制裁名单),宣布将“抵制”所有与黑名单地址相关的交易。

这种攻击的严重程度取决于“抵制联盟”掌握的算力。如果抵制联盟的算力低于 51%,被抵制的交易仍然可以被确认,但它们必须等待那些“不参与抵制”的矿池(例如只占 30% 算力)来挖到区块。

如果抵制联盟的算力超过 51%。他们不仅自己不打包这些交易,他们还会故意孤立(orphaning)任何包含了这些交易的诚实区块(因为他们总能挖出更长的链)。结果就是被抵制的交易将永远无法被打包上链。这等同于将某个用户或应用永久地“踢出”了网络

这种攻击并不像“双花”那样直接窃取资产,但它直接攻击了区块链最核心的价值主张之一:抗审查性(Censorship Resistance)和中立性(Neutrality)

51% Attack

当单个矿工或矿池掌握了全网超过 50% 的算力时,他们就有能力制造出一条比诚实网络更长的“分叉链”。攻击者可以在主链上将 BTC 发送给商家(例如,换取法币或商品),然后在自己的分叉链上构造一笔交易将同样的 BTC 发送回自己的地址。当他的分叉链长度超过主链并被网络接受时,之前给商家的交易就被“撤销”了,从而实现Double Spending。

但是这样的攻击需要天价的硬件和电力成本,而且成功攻击会摧毁人们对 BTC 的信心,导致币价暴跌,攻击者自身的收益也会大打折扣,,因此在经济上是不理智的。

虽然在BTC上不太可能这么做,但是这一点在那些算力较低的山寨币(altcoins)上体现得淋漓尽致。攻击者可以按小时租用强大的算力(OPEX)。对于一个小币种来说,攻击者可能只需租用比特币网络总算力的一小部分,就能轻松达到该小币种网络51%以上的算力。

Selfish Mining

攻击者,不需要 51% 的算力(理论上超过25%就有可能获利)。攻击者挖到区块后并不立即广播,而是选择不发布,并基于这个秘密区块继续挖下一个。

当诚实矿工挖到区块A时,自私矿工如果已经秘密挖到了区块A’和B’(比诚实链长),他就会立刻广播自己的A’和B’。网络会接受更长的链,导致诚实矿工的区块A作废,他们的算力被浪费。

为了防范这种攻击,在比特币中一笔交易通常需要等待6个区块的“确认”(Confirmations)后才被认为是最终、不可逆转的,这大约需要一个小时的时间。因为每增加一个确认,攻击者就需要付出更多的算力和时间来追赶并超越诚实的区块链。

中本聪的计算表明,如果一个攻击者掌握了10%的网络算力,那么在6个区块确认之后,他成功实现双花攻击的概率已经下降到了0.1%以下。这个概率被认为足够低,可以保障绝大多数商业交易的安全。“6个确认”因此成为了一个在安全性与用户体验之间取得平衡的行业“黄金标准”。

分叉

硬分叉

“硬”分叉 (Hard Fork) 是一种永久性的、不向后兼容的“规则升级”。当硬分叉发生时,旧版本的软件(节点)将不再接受新版本软件(节点)创建的区块,导致区块链永久性地分裂成两条不同的链

它强制要求所有参与者(矿工、节点、交易所、钱包)必须升级到新软件。如果你不升级,你就会被“留在”旧的、即将被淘汰的链上。

硬分叉在 BTC 最著名的例子就是 Bitcoin Cash (BCH),它在 2017 年 8 月从 BTC 硬分叉出去。

  • 分歧点: 如何解决比特币的“拥堵”(扩容)问题。们认为 1MB 的上限(以及 SegWit 的 4M WU)太小了,于是他们通过硬分叉,直接把区块大小上限改到了 8MB,后来又改到 32MB。
  • BTC 阵营(旧规则): 选择“隔离见证”(SegWit),这是一种复杂的“软分叉”升级(可以理解为优化道路,让每辆车坐更多人)。
  • BCH 阵营(新规则): 认为 SegWit 太复杂,主张简单粗暴的“硬分叉”——直接把区块大小上限(1MB)提高到 8MB(可以理解为直接把两车道公路扩建成八车道)。
  • 结果:
    1. BCH 修改了规则代码,并在特定区块高度激活。
    2. 坚持旧规则的节点继续留在 BTC 链上。
    3. 运行新规则的节点分裂出去,形成了 Bitcoin Cash 链。
    4. 新币诞生: 在分叉的那一刻,如果你持有 1 个 BTC,你现在会同时拥有 1 个 BTC(在原始链上)和 1 个 BCH(在新链上)。

这里有个有意思点,如果是在硬分叉之前拥有了某个加密货币,那么在分叉之后将同时拥有“原始货币”和所有“新分叉出来的货币”。

软分叉

软分叉 (Soft Fork) 是一种向后兼容的升级,可以认为是把旧规则变得更严格。

比如,以前的规则是“区块大小不能超过 1MB”。软分叉的新规则是“区块大小不能超过 1MB,并且里面必须包含 A 数据”。旧节点(只懂旧规则)看到新区块时,会觉得:“它没超过 1MB,合法。”(它看不懂 A 数据,但不影响)。新节点会严格执行新规则。

这样只要大多数矿工升级,网络就会被“拉”到新规则上,而不会像硬分叉那样导致区块链分裂

SegWit (Segregated Witness,隔离见证)就是btc链上一次最重大技术升级实现的软分叉。在 SegWit 之前,比特币网络面临两个主要问题:

  1. 在比特币交易被确认(打包进区块)之前,任何人都可以轻微地修改这笔交易的数字签名(scriptSig),而不会使交易失效,这种修改会导致交易ID(txid)发生变化。如果这个txid在交易确认前可以被篡改,就会引发严重问题,比如双花攻击的变种,或者让依赖 txid 的复杂合约(如闪电网络)变得极难实现。
  2. 比特币的区块大小被限制在 1MB,数字签名(见证数据)通常会占据一笔交易 60% 甚至更多的空间。这些数据都挤在 1MB 的空间里,大大限制了每个区块能容纳的交易数量。

SegWit 通过改变交易和区块的数据结构,重新定义了一笔交易的结构。它把交易分为两个部分:

  1. 核心交易数据 (Base Transaction Data)
    • 包含:发送方地址、接收方地址、金额等。
    • 不包含: 数字签名和解锁脚本。
    • 关键点: 交易ID(txid只根据这部分数据来计算。
  2. 见证数据 (Witness Data)
    • 包含:所有的数字签名和解锁脚本(即 scriptSigscriptWitness)。
    • 这部分数据被“隔离”出来,存放在交易的一个新字段中。

由于 txid 现在只根据核心交易数据计算,而签名(scriptSig)这个唯一可以被延展(篡改)的部分已经被移到了见证数据中,不再参与 txid 的计算。因此,一旦交易发出,其 txid 就被永久固定,交易延展性问题被彻底解决。

再来就是SegWit 并没有直接把 1MB 的区块大小限制(Block Size)改掉,而是引入了一个全新的概念:区块权重 (Block Weight)

  • 旧规则: 区块大小上限为 1MB
  • 新规则: 区块“权重”上限为 4,000,000 WU (Weight Units)

这个权重的计算方式是:

  • 1 字节核心交易数据 = 4 WU
  • 1 字节见证数据 = 1 WU

那么:

  1. 如果一个区块全是老式交易(数据和签名混在一起),所有数据都按 4 WU/字节 计算,那么 4,000,000 WU/ 4 = 1,000,000 字节,区块大小仍然是 1MB
  2. 如果一个区块全是SegWit交易(签名被隔离),大部分数据(签名)都按 1 WU/字节 计算。这使得区块可以塞下更多的交易数据。

在理想的(全是SegWit交易的)情况下,一个区块的实际物理大小可以达到接近 4MB,但其“权重”仍然是 400 万 WU。

北大《区块链技术与应用》——BTC篇最先出现在luozhiyun`s Blog