MiniClaude:个人 Agent 早期版本学习笔记 · 2
02-类型系统与注解盲区梳理
本文以 Python 3.12、Pydantic v2 为基线,示例是独立教学代码,不代表某个 MiniClaude 提交的实现。Python 3.14 的注解变化单独说明。基础语法、数据类默认值见 01-Python 语法盲区梳理;协程怎样运行、任务怎样收尾见 03-asyncio 与并发模型盲区梳理。本篇依次看检查、导入、模型、接口与事件解码。
1 类型注解不等于运行时验证
下面两个入口都写了 int,但真正执行的工作不同:
from pydantic import BaseModel, ValidationError
def echo(value: int) -> int:
return value
class User(BaseModel):
age: int
print(type(echo("18")).__name__) # str,故意违反注解
user = User.model_validate({"age": "18"})
print(user.age, type(user.age).__name__) # 18 int
for data, strict in [({"age": "abc"}, False), ({"age": "18"}, True)]:
try:
User.model_validate(data, strict=strict)
except ValidationError as exc:
print(exc.errors()[0]["type"]) # int_parsing;int_type
普通函数的参数和返回值注解不会自动拦截这次调用。若 add(a: int, b: int) 的函数体执行 a + b,传入 "1", 2 会报错,原因是字符串和整数不能这样相加,而不是注解先检查了实参。
mypy 检查源码,Pydantic 验证送进来的数据。 mypy 根据注解、赋值和调用关系发现不一致,不需要执行上述函数。安装后可用 python -m mypy checks.py 检查保存的代码;编辑器、命令行、CI 都能接入,检查失败是否阻止合并由流程决定。覆盖程度还取决于配置和类型信息。注解写成字符串也不妨碍静态分析。Python 类型注解说明
Pydantic 在构造或显式验证时处理真实输入,默认允许某些转换;本例的 "18" 可变成整数,"abc" 不行,strict=True 又会拒绝字符串数字。严格规则依字段和 Python/JSON 输入来源而异,不能把整数例子推广到所有类型。两种检查互补:静态通过不保证外部输入合法,模型通过也不保证后续业务正确。Pydantic 严格模式
cast 和 TypeGuard 各做了什么
from typing import TypeGuard, cast
raw: object = ["goal", 7]
required = cast(list[str], raw)
print(required is raw) # True,没有转换或检查元素
def is_str_list(value: object) -> TypeGuard[list[str]]:
return isinstance(value, list) and all(isinstance(x, str) for x in value)
print(is_str_list(raw)) # False
candidate: object = ["goal", "name"]
if is_str_list(candidate):
print(", ".join(candidate)) # goal, name;检查器在此收窄类型
cast(T, x) 适合“已有证据确认类型,但检查器推不出来”的地方,不适合拿来验证外部输入。TypeGuard 的保护来自函数体里的真实检查;注解只告诉检查器如何理解 True,无条件返回真仍会误导它。
还要分清:dict(data) 构造字典,由已有字典构造时只是浅拷贝;dict[str, object] 是类型表达式,本身不验证数据。原笔记中的 tool.params_model.model_validate(dict(tool_call.input)),就是先构造字典,再调用模型验证。是否把 ValidationError 转成 schema_error,要继续查调用方的捕获逻辑,不能从这一行或 cast 推定整个入口已安全。
2 导入错误先看名字何时可用
首次正常导入模块会执行顶层代码。模块尚未执行完,就可能因另一个 import 暂停;此时它已经进入缓存,但部分名字还没定义。循环依赖不一定报错,提前取出不存在的名字才是关键。
Java 的 import 主要参与名称解析,不能直接套用 Python 的顶层执行顺序;Java 也有类初始化问题,不宜概括成“Java 没有循环问题”。
前向引用与 future 的保护范围
以下是一个完整的独立文件,不能把 future 语句追加到已有普通代码后面:
from __future__ import annotations
from typing import get_type_hints
def make() -> B:
return B()
class B:
pass
print(make.__annotations__["return"]) # B,保存的是字符串
print(get_type_hints(make)["return"].__name__) # B,解析得到类
print(type(make()).__name__) # B,这时才构造对象
在 Python 3.12 中,删掉第一行,定义 make 时就会因注解里的 B 尚未定义而报 NameError,不必等调用。也可以只把返回注解写成 "B"。future 启用的是 PEP 563 的字符串化行为;前面允许有模块文档字符串、注释、空行及其他 future 语句,但不能先执行普通语句。
return B() 仍在调用时执行,那时 B 已定义就可以正常工作。future 不会推迟普通 import、基类表达式、顶层构造和真实调用;前面失败的 from a import A 加上它仍然失败。不能把函数体中的正常引用一律标成错误。
TYPE_CHECKING 减少类型依赖,也可能留下解析缺口
这是类型导入片段,静态检查的前提是项目里确有 provider.py 和 Provider:
from __future__ import annotations
from typing import TYPE_CHECKING, get_type_hints
if TYPE_CHECKING:
from provider import Provider
def use(p: Provider) -> Provider:
return p
检查器把 TYPE_CHECKING 视为真,运行时它为假,因此不会为了注解导入 provider。可是直接调用 get_type_hints(use) 会因当前命名空间没有 Provider 而失败。等真实类可用后,可以提供包含它的 globalns,例如 get_type_hints(use, globalns={**globals(), "Provider": provider.Provider});这要求已在合适时机 import provider。
“能导入”不等于“框架能解析全部注解”。Pydantic 等库也需要正确的名称和解析时机,不能把运行时需要的导入全部藏进 TYPE_CHECKING。共享类型可下移到第三个底层模块;真实依赖可在合适时机导入;顶层互相构造对象则应调整初始化职责。拆一个公共模块不必直接拔高成“依赖倒置”。
3.14 的边界: PEP 649/749 让注解默认延迟求值,与 3.12 的字符串化机制不同;过早索取实际值仍可能遇到未定义名字。from __future__ import annotations 在 3.14 中仍保留字符串化行为,没有被删除。升级时还要检查读取注解的库是否兼容。Python 3.14 注解变化
3 模型负责怎样接收和交出数据
标准库 @dataclass 根据字段生成构造、显示、比较等样板,默认可变,不自动验证字段类型;它也能用 asdict() 处理嵌套数据类,或在 __post_init__ 中自行检查。field(default_factory=list) 为每个需要默认值的实例新建列表,这部分在 01 展开。
Pydantic 的 BaseModel 把验证和导出规则放在模型旁边。它不是 Python 的 object,也不是负责表映射、查询与持久化的 ORM Model。内部可信数据可用轻量数据类,外部输入或固定协议通常适合验证模型,但这不是硬分工:配置虽然在本进程消费,环境变量、用户文件和命令行参数仍可能不可信;两类模型完全可以同时使用。dataclass 与 Java record、BaseModel 与 Bean Validation/Jackson 都只能局部类比,不能直接等同。
Literal 的允许值与默认值是两件事
str 允许字符串,Literal["2.0"] 进一步限定为这个具体值;Literal["fast", "slow"] 则列出两个允许值,常用于协议版本、状态和事件标签。Literal 不会像 Java enum 那样创建独立的枚举类型。普通函数注解不会因此自动检查;放进 Pydantic 字段才由模型执行验证。字符串 "2.0" 也不同于数值 2.0。
下面沿“构造结果 → 包装回复 → 导出 → 读回”走一遍:
from typing import Literal
from pydantic import BaseModel
class PongResult(BaseModel):
server_version: str
uptime_ms: int
received_at: str
class JsonRpcSuccess(BaseModel):
jsonrpc: Literal["2.0"] = "2.0"
id: str
result: PongResult
pong = PongResult(server_version="0.1.0", uptime_ms=42, received_at="2026-07-31")
response = JsonRpcSuccess(id="1", result=pong)
payload = response.model_dump()
wire = response.model_dump_json()
print(type(payload["result"]).__name__) # dict
print(type(JsonRpcSuccess.model_validate(payload).result).__name__) # PongResult
print(type(JsonRpcSuccess.model_validate_json(wire).result).__name__) # PongResult
= "2.0" 让构造时可以省略 jsonrpc;Literal["2.0"] 则拒绝显式传入的 "1.0" 或数值 2.0。仅写 jsonrpc: str = "2.0" 不能限制其他字符串。
model_dump() 的 Python 模式可能保留日期等对象;要 JSON 兼容数据用 model_dump(mode="json"),要文本才用 model_dump_json()。特殊对象仍可能需要序列化支持,不能承诺任意对象都零配置。Pydantic 序列化
能导出,不代表能恢复原类型。 若上例把 result: PongResult 改成 result: Any(先从 typing 导入 Any),嵌套模型仍能导出;从这份 JSON 读回时却只会得到 dict,因为 Any 没告诉库要恢复成 PongResult。多种回复应按需要设计联合类型或其他约束,使用 Any 就要清楚放宽了哪层检查。一次验证成功也不保证之后任意赋值自动重验,赋值验证需要另行配置。
4 Protocol 与 ABC 约束的是对象能力
模型主要规定“数据长什么样”,接口还规定“对象能做什么”。先看调用方真正需要的能力:
import asyncio
from typing import Protocol
class LLMProvider(Protocol):
async def chat(self, messages: list[str]) -> str: ...
class DemoProvider:
async def chat(self, messages: list[str]) -> str:
return "hi"
async def use(provider: LLMProvider) -> str:
return await provider.chat(["hello"])
print(asyncio.run(use(DemoProvider()))) # hi
DemoProvider 没有继承 LLMProvider,仍可按结构满足接口,适合第三方适配、替换实现和测试替身。有同名方法不够:参数、返回值、所需属性及同步/异步调用形式都要兼容。 签名不必逐字相同,但必须支持接口承诺的用法。普通 Protocol 不在每次调用前验证;@runtime_checkable 支持的部分 isinstance 检查,也不验证完整签名和业务行为。Protocol 规则
ABC 是标准库 abc 模块的抽象基类机制,常用显式继承组织共享逻辑,并阻止未实现抽象成员的类实例化:
from abc import ABC, abstractmethod
class BaseTool(ABC):
name: str
description: str
input_schema: dict[str, object]
@abstractmethod
async def invoke(self, params: dict[str, object]) -> str: ...
class BareTool(BaseTool):
async def invoke(self, params: dict[str, object]) -> str:
return "done"
print(hasattr(BareTool(), "name")) # False,但可以实例化
# BaseTool() 或未实现 invoke 的子类实例化,会抛 TypeError
name: str 没有赋值,也不是抽象属性。需要强制子类提供属性,可用 @property 配合 @abstractmethod;名称格式、schema 内容、覆写签名和返回值仍需静态检查、运行时检查或测试。ABC 还支持虚拟子类:注册不注入方法,也不会按普通继承路径强制补齐抽象成员,因此不能说所有被视为某 ABC 的类都必须显式继承。ABC 官方说明
Protocol 可借 Java interface 理解,但通常不要求 implements;ABC 可借 abstract class 理解,也不能照搬所有继承规则。两者按能力需求选择,都不能替代输入模型验证。读具体工具框架时,还要查注册流程是否另外检查元数据。
5 事件 JSON 怎样选中正确模型
事件有共同入口,却有不同字段。判别联合把“根据标签选模型,再验证字段”集中在声明中:
from typing import Annotated, Literal
from pydantic import BaseModel, Field, TypeAdapter
class RunStarted(BaseModel):
type: Literal["run.started"] = "run.started"
run_id: str
class StepStarted(BaseModel):
type: Literal["step.started"] = "step.started"
step: int
Event = Annotated[RunStarted | StepStarted, Field(discriminator="type")]
adapter = TypeAdapter(Event)
event = adapter.validate_json('{"type":"run.started","run_id":"r1"}')
print(type(event).__name__) # RunStarted
print(event.model_dump_json()) # {"type":"run.started","run_id":"r1"}
Literal 定标签;联合列候选,A | B 从 3.10 起可用,也可写 Union[A, B];Annotated 附加元数据,Field 指定给 Pydantic 读的判别字段;TypeAdapter 让这个类型表达式获得运行时验证等能力,无须再包 BaseModel。Event 是类型别名,不是一条事件实例。
把不同输入送进上例 adapter,失败位置不同,都会通过 ValidationError 报告:
只有 Literal 字段也不等于显式配置 discriminator。普通 TypeAdapter(RunStarted | StepStarted) 能验证联合,但其 schema 没有这里的 discriminator 声明;adapter.json_schema()["discriminator"]["propertyName"] 才得到 type。Pydantic v2 另有支持字符串或可调用逻辑的 Discriminator;v1.10 已有显式判别联合,不是“v1 自动推断,v2 才显式声明”。v2 联合类型、v1 判别联合
与手写分支相比,省下什么
手写解析同样可以正确:json.loads 后先确认是字典,再按 type 分支调用相应的 model_validate,对未知/缺失标签报错。它可以集中成一个函数,也可以用统一映射分派,不必每处重写 if-else。
判别联合的价值是集中候选模型、标签关系、验证与 schema,错误更聚焦,减少无关分支尝试。JSON 解析、容器遍历、字段与嵌套模型验证仍有成本,不能说整个反序列化是 O(1),也不能仅凭写法断定一定比手写映射快。新增事件还要定义模型、稳定且不冲突的标签、加入联合,并检查消费者、日志/界面展示和旧客户端兼容;“只改一处、自动满足开闭原则”都过头了。
进程内可直接传模型对象;socket、历史日志通常需要重新解码。但判别联合也能用 validate_python 处理 Python 数据,不限于 IPC。熟悉 Jackson 的话,可类比 @JsonTypeInfo 与子类型映射的选型思路,不等于两个库行为完全相同。
手写分支展开步骤,带有命令式特点;模型声明表达允许什么结构,把执行交给库,带有声明式特点。两者可以共存。SQL 是更典型的声明式例子,正则、HTML、Spring DI 的配置也可帮助理解“描述规则或结果”;列表推导式虽比 for/append 集中,仍包含遍历与条件。map/filter/reduce、EventBus 订阅配置也不能替整个程序贴标签,库内部仍有判断和循环。学 Agent 时,先读清发布订阅、依赖注入、接口和一次 ReAct 运行循环,不必另背“范式在第几层”的金字塔。
读回源码时,沿一条数据查
输入从哪里来,谁验证,用什么对象传递,在哪里导出,由谁读回。类型不一致看静态检查;导入失败看顶层执行和求值时机;字段错误看验证入口;能力不兼容看签名;事件失败先查标签再查字段。这些检查不是互斥的,也不会因 Any、绕过入口或缺少配置而自动补齐。
原学习记录中的 core/tools/invocation.py、bus/events.py、S0/S1/S2 是回查线索。具体事件数量、某阶段是否调用解码、源码是否使用 cast,都应核对对应提交,不能从本篇教学例推定。
系列目录 · MiniClaude:个人 Agent 早期版本学习笔记
- 1
- 2
- 3
- 4
- 5
- 6