Python `Annotated` 与 LangGraph Reducer 学习笔记

20 阅读5分钟

学习目标:

理解三个问题:

  1. Annotated 到底是什么?
  2. LangGraph 为什么要使用 Annotated
  3. Reducer 到底是什么,它是如何工作的?

# # 一、为什么会有**Annotated****?**

Python 本身有类型提示(Type Hint):

name: str
age: int
scores: list[int]

这些类型提示主要有两个作用:

  • 帮助 IDE 自动补全
  • 帮助静态类型检查(mypy、pyright)

但是,仅仅知道一个变量是 intlistdict,很多框架是不够的。

例如:

FastAPI 希望知道:

这个参数最大长度是多少?

Pydantic 希望知道:

这个字段必须大于 0。

LangGraph 希望知道:

这个 State 更新的时候应该怎么合并?

这些信息都不是类型

于是 Python 在 PEP 593 中引入了:

typing.Annotated

它的作用只有一句话:

给一个类型附加任意元数据(metadata)。

语法:

Annotated[Type, metadata1, metadata2, ...]

例如:

from typing import Annotated

Age = Annotated[int, "must >18"]

这里:

真正的类型:

int

元数据:

"must >18"

Python 不会理解 "must >18"

Python 只是负责:

帮你保存起来。

因此:

Annotated = 类型 + 元数据


二、Python 本身不会解析 Annotated

这是整个知识点最重要的一件事情。

很多人第一次看到:

Annotated[int, add]

都会觉得:

Python 会不会自动调用 add?

答案:

不会。

Python 什么都不会做。

它只是保存:

Annotated
    │
    ├── 真正类型:int
    └── metadata:add

就结束了。

Python 根本不知道:

  • add 是函数
  • Query 是什么
  • Field 是什么
  • reducer 是什么

这些都是框架定义的。

可以把 Python 理解成:

一个负责存档的人。

而真正阅读档案的是:

  • FastAPI
  • Pydantic
  • LangGraph

三、Annotated 内部到底保存了什么?

例如:

from typing import Annotated

Age = Annotated[
    int,
    "positive",
    "required"
]

实际上里面保存的是:

Annotated
    │
    ├── int
    ├── "positive"
    └── "required"

可以使用:

from typing import get_origin
from typing import get_args

查看。

例如:

from typing import Annotated
from typing import get_origin
from typing import get_args

Age = Annotated[
    int,
    "positive",
    "required"
]

print(get_origin(Age))
print(get_args(Age))

输出:

typing.Annotated

(
    int,
    "positive",
    "required"
)

因此:

args[0]

永远是真正类型。

后面:

args[1:]

全部都是 metadata。

例如:

Annotated[
    list,
    add,
    Cache(),
    "important"
]

得到:

(
    list,
    add,
    Cache(),
    "important"
)

Python 不会解释:

  • add
  • Cache()
  • important

只是全部保存下来。


四、框架是如何解析 Annotated 的?

真正解析 Annotated 的,是框架。

例如 LangGraph。

假设:

from operator import add
from typing import Annotated
from typing_extensions import TypedDict

class State(TypedDict):

    messages: Annotated[list, add]

    score: int

框架首先需要读取 State。

一般会使用:

from typing import get_type_hints

hints = get_type_hints(
    State,
    include_extras=True
)

这里:

## ## 为什么一定要**include_extras=True****?**

这是很多人容易忽略的一点。

默认:

get_type_hints(State)

得到:

{
    "messages": list,
    "score": int
}

所有 metadata 都丢失了。

因为:

默认行为认为:

metadata 不影响类型检查。

所以自动去掉。

只有:

include_extras=True

Python 才会保留:

{
    "messages": Annotated[list, add],
    "score": int
}

框架才能继续解析。


然后:

for field_name, annotation in hints.items():

    if get_origin(annotation) is Annotated:

        args = get_args(annotation)

        real_type = args[0]

        metadata = args[1:]

对于:

messages: Annotated[list, add]

最终得到:

field_name

↓

messages

real_type

↓

list

metadata

↓

(add,)

框架随后就可以:

reducers["messages"] = add

保存下来。

因此:

Annotated 自己什么都没做。

真正工作的,是:

框架读取 metadata。


五、LangGraph 为什么需要 Annotated?

LangGraph 有一个核心概念:

State

例如:

class State(TypedDict):

    messages: list

每个 Node:

都可以返回:

return {

    "messages": ...
}

LangGraph 会更新 State。

问题来了。

假设:

旧 State:

{
    "messages": [
        "Hi"
    ]
}

Node 返回:

{
    "messages": [
        "Hello"
    ]
}

最后:

messages 应该变成:

方案一:

["Hello"]

还是:

方案二:

["Hi", "Hello"]

Python 根本不知道。

所以:

LangGraph 必须让开发者告诉它:

更新规则是什么。

于是:

messages: Annotated[list, add]

出现了。

这里:

list

表示:

类型。

add

表示:

更新规则。


六、Reducer 到底是什么?

Reducer:

本质就是:

Merge Strategy(合并策略)

或者:

State 更新策略

它的定义非常简单:

(old_value, new_value)

↓

merged_value

例如:

def reducer(old, new):

    ...

返回:

新的 State。


例如:

最经典:

from operator import add

实际上:

add(a, b)

就是:

a + b

例如:

旧:

["Hi"]

新:

["Hello"]

Reducer:

add(
    ["Hi"],
    ["Hello"]
)

结果:

["Hi", "Hello"]

如果没有 reducer:

LangGraph 默认:

覆盖

即:

result = new_value

最终:

["Hello"]

七、为什么 Reducer 很重要?

因为:

LangGraph 是一个 Graph。

不是:

Node1

↓

Node2

↓

Node3

它支持:

          START


      Retrieve Docs

      /            \

Search DB      Search Web

      \            /

       Merge Result


           END

其中:

Search DB

和:

Search Web

可能:

同时运行。

同时返回:

{
    "documents": [...]
}

例如:

Search DB:

{
    "documents": [
        "doc1",
        "doc2"
    ]
}

Search Web:

{
    "documents": [
        "doc3"
    ]
}

如果没有 reducer:

LangGraph 就会遇到一个问题:

到底:

保留:

doc1
doc2

还是:

doc3

还是:

全部保留?

Reducer 就是解决:

多个更新如何合并。

于是:

documents: Annotated[
    list,
    add
]

最终:

old + new

得到:

[    "doc1",    "doc2",    "doc3"]

八、Reducer 可以自己写

Reducer 其实就是普通函数。

例如:

保留最大值:

def keep_max(old, new):

    return max(old, new)

然后:

score: Annotated[
    int,
    keep_max
]

更新:

80

↓

90

得到:

90

如果:

95

↓

90

得到:

95

例如:

聊天记录去重:

def unique_messages(old, new):

    return list(
        dict.fromkeys(
            old + new
        )
    )

然后:

messages: Annotated[
    list,
    unique_messages
]

即可自动去重。

因此:

Reducer:

其实只是:

(old, new)

↓

merge(old, new)

九、FastAPI、Pydantic 与 LangGraph 的共同思想

FastAPI:

Annotated[    str,    Query(max_length=20)]

Pydantic:

Annotated[
    int,
    Field(gt=0)
]

LangGraph:

Annotated[
    list,
    add
]

它们都遵循同一个模式:

Python

↓

保存 metadata

↓

框架读取 metadata

↓

框架赋予 metadata 业务含义

区别只是:

框架Metadata 示例框架赋予的含义
FastAPIQuery(max_length=20)请求参数校验与 OpenAPI 描述
PydanticField(gt=0)数据验证规则
LangGraphaddState 更新时的 reducer(合并策略)

也就是说:

Python 并不知道:

Query()

是什么。

也不知道:

Field()

是什么。

更不知道:

add

为什么表示 reducer。

这些全部都是:

框架自己的约定(Convention)。


十、整套流程总结(核心)

开发者

↓

messages: Annotated[list, add]

↓

Python

↓

保存:
(list, add)

↓

LangGraph

↓

get_type_hints(include_extras=True)

↓

发现:

messages

↓

Annotated[list, add]

↓

get_args()

↓

(
    list,
    add
)

↓

LangGraph 解释:

add 是 reducer

↓

保存:

reducers["messages"] = add

↓

以后 State 更新:

old_value = ["Hi"]

new_value = ["Hello"]

↓

调用:

add(old_value, new_value)

↓

得到:

["Hi", "Hello"]

一句话总结

  • Annotated 是 Python 提供的“元数据容器”,负责把额外信息附着到类型上,但不会解释这些信息。
  • 框架(如 LangGraph、FastAPI、Pydantic)负责读取 Annotated 中的 metadata,并赋予它业务含义。
  • 在 LangGraph 中, Annotated 最常见的用途就是指定 Reducer ——即多个节点更新同一个 State 字段时,应该如何合并旧值和新值。