MiniClaude:个人 Agent 早期版本学习笔记 · 1
01-Python 语法盲区梳理
读 MiniClaude 时,先把一行代码拆成几个动作:创建了什么对象、改了哪一层数据、名字绑定在哪里、何时才会求值。本篇把原来的 16 个语法点合并到这些场景里,避免同一条规则反复讲。
三篇分工:本篇讲对象、作用域、表达式与导入;第 02 篇讲类型检查、运行时验证与接口;第 03 篇讲异步执行和资源收尾。Literal["2.0"] 表达“期望值为字符串 "2.0",不是任意字符串或数字 2.0”,也可列举多个允许值,但普通 Python 调用不会自动执行这个检查,详见第 02 篇。
1. 为每次运行建立独立状态
普通类通常在 __init__ 中给 self 赋属性;只写 goal: str 不会替普通类生成构造方法或验证器。@dataclass 会按字段生成初始化、显示、相等比较等样板方法,默认仍可修改,也不自动验证字段类型。
from dataclasses import dataclass, field, asdict
from typing import Any
@dataclass
class ExecutionContext:
goal: str
prefill_messages: list[dict[str, Any]] = field(default_factory=list)
messages: list[dict[str, Any]] = field(default_factory=list)
def __post_init__(self):
if self.prefill_messages:
self.messages = [dict(m) for m in self.prefill_messages]
elif not self.messages:
self.messages.append({"role": "user", "content": self.goal})
a = ExecutionContext("读配置")
b = ExecutionContext("写总结")
a.messages.append({"role": "user", "content": "继续"})
print(a.messages is b.messages) # False
print(asdict(b)["messages"]) # [{'role': 'user', 'content': '写总结'}]
print(type(ExecutionContext(123).goal).__name__) # int,注解没有拦截
这里有三层规则:
-
工厂负责默认对象。
field()是字段配置函数,default_factory=list传入可调用对象list,需要默认值时才零参数调用;不能写成已经创建的list()。显式传入列表时,生成的初始化方法直接使用它,不替你复制;上例后续仍可能由__post_init__改动或替换字段。 -
钩子负责补充初始化。 生成的
__init__先赋字段,再在自身内部调用__post_init__,并非__init__返回后的外部回调。名字拼错不会被识别。可以自写__init__,但此时字段赋值和钩子调用都要自己负责。上例优先使用非空历史,否则只在没有消息时加入 goal;dict(m)只复制每条消息的外层。 -
导出也有复制语义。
asdict()递归处理嵌套 dataclass、字典、列表、元组,其他对象用deepcopy;它不是浅拷贝,也不是 JSON 文本或任意对象都能安全复制的保证。dataclasses 文档
field(default=0) 与直接 = 0 都给现成默认值;要配置 compare=False、init=False 等选项时再用 field(...)。messages: list = [] 和 field(default=[]) 在这里都会在类定义阶段被 dataclass 拒绝,抛出 ValueError。Python 3.11 起采用“不可哈希默认值”近似识别共享风险,并不能识别所有可变对象。
普通函数却允许可变默认参数,而且默认表达式只在定义时求值一次:def add(x, basket=[]): ... 的省略参数调用会共享那份列表。需要每次新建时,用 None 哨兵:
def add(message: str, *, history=None):
if history is None:
history = []
history.append(message)
return history
print(add("A"), add("B")) # ['A'] ['B']
saved = []
print(add("C", history=saved) is saved) # True
这样既隔离默认状态,也保留调用者显式传入的空列表。Java 没有同样的默认参数语法,但共享可变对象仍可能造成状态串扰。
2. 从函数签名读出调用约束
上例单独的 * 是分隔符:后面的普通参数只能按 参数名=值 传入。add("C", history=saved) 合法,add("C", saved) 会抛出 TypeError。调用中的 func(*args) 则是把可迭代对象展开为位置实参,两者语境不同。
原记录在 AgentRunner.__init__、TracingProvider.__init__ 遇到此写法,例如 def __init__(self, inner, trace, *, include_payload=True)。关键字让调用意图更清楚,减少同类型参数传错顺序;后来增加有默认值的关键字参数,通常不用改旧调用。Java 的 Builder 能改善复杂调用的可读性,但没有与此完全相同的关键字参数语法。
3. 闭包保留绑定,nonlocal 决定改哪一个名字
嵌套函数实际引用了外层函数的局部变量,才形成这里讨论的闭包;单纯把函数写在另一个函数里不一定捕获变量。外层返回后,被闭包引用的绑定仍可以继续存在。
def make_counter():
count = 0
def counter():
nonlocal count
count += 1
return count
return counter
c = make_counter()
print(c(), c()) # 1 2
读取外层名字不用声明,重新绑定才需要 nonlocal。 它找最近一个已经绑定该名字的外层函数作用域,不会转去找模块全局;模块顶层不能用它。global x 则让函数内对 x 的绑定指向当前模块。没有这两种声明,函数内的赋值通常形成局部名字:count = 1 可以新建局部绑定,而 count += 1 先读后写,未赋值就读会触发 UnboundLocalError。
items.append(x)、self.status = "done" 改的是对象内容,没有重新绑定 items 或 self,不因此需要 nonlocal。原记录 tui/app.py 的 on_event 调用 self._handle_event(event),引用的是 self,没有引用 log,也不重绑 self。cli/commands/run.py 的回调则在失败事件中执行 exit_code = 1,需要 nonlocal exit_code;同一回调里的 finished.set() 不需要。回调的异步调度另见第 03 篇。
闭包通常在调用时读取绑定的当前值,不自动保存每轮循环的值:
readers = [lambda: i for i in range(3)]
fixed = [lambda i=i: i for i in range(3)]
print([f() for f in readers]) # [2, 2, 2]
print([f() for f in fixed]) # [0, 1, 2]
默认参数在每次定义函数时固定本轮值;也可用工厂函数创建独立绑定。Java lambda 捕获的外层局部变量必须是 final 或 effectively final,不能重新赋值,但其引用的可变对象仍可能被修改。
4. 整理数据时,分清取值、转换与复制
tool_input = {"options": {"verbose": False}}
params = dict(tool_input)
params["new_key"] = "x"
params["options"]["verbose"] = True
print("new_key" in tool_input) # False
print(tool_input["options"]["verbose"]) # True
raw = {"blocked_by": ["3", "2", "3"]}
ids = [int(x) for x in raw.get("blocked_by", [])]
print(list(dict.fromkeys(ids))) # [3, 2]
messages = []
print(messages[-1] if messages else None) # None
构造与类型表达式。 dict(...) 调用构造器;输入是字典时,只复制外层,嵌套对象仍共享,不能据此保证事件参数是独立快照。需要深层隔离时,按对象内容与业务决定是否适用 copy.deepcopy。dict[str, Any] 是泛型别名表达式,常用于注解;先 from typing import Any 后,普通赋值 Config = dict[str, Any] 也会在运行时创建别名。方括号不代表“不执行”,类型参数也不会自动校验内容。内置泛型从 3.9 起支持,旧版常见 typing.Dict;Python 3.12 与 3.14 的注解求值差异见第 02 篇。
推导式与生成器。 [表达式 for x in 数据 if 条件] 把逐项转换和过滤收集成新列表,例如 [x * x for x in range(10) if x % 2 == 0] 得到 [0, 4, 16, 36, 64]。原记录 registry.py 的 tool_schemas() 据此把工具的 name、description、input_schema 整理成字典列表;Task.from_dict 则把依赖 ID 转为整数。get(..., []) 只处理缺键,已有值为 None 或元素不能转整数仍会失败,推导式不补做验证。
import fnmatch
event_type = "run.finished"
topics = ["step.*", "run.*"]
print(any(fnmatch.fnmatch(event_type, p) for p in topics)) # True
这是原记录 core/app.py 的 glob 过滤思路:生成器按需产出结果,any() 遇到真值即停止,不先建列表。还有字典推导式 {k: v for ...}、集合推导式 {x for ...}。Java Stream 的 map/filter 加收集可帮助理解,但不是所有执行规则都等价;复杂条件或多步副作用用普通 for 更清楚,不必为“推导式一定更快”改写。
索引与条件表达式。 X if 条件 else Y 先判断,只求被选中的分支,可以放在赋值右边或实参里;对应 Java 的 条件 ? X : Y。所以上例空列表没有执行 [-1]。对 list、tuple、str、bytes 等内置序列,[-1] 是末项,[-2] 是倒数第二项,按 len(seq) + index 换算后仍检查越界。空列表单独取 [-1] 会抛 IndexError;[-2:] 是末两项切片,短列表只返回能取得的部分,空列表仍返回空列表。
去重与容器转换。 list(set(old_ids + new_ids)) 依次拼接、去重、转回列表,不保证输入顺序;list(dict.fromkeys(items)) 保留首次出现的顺序,字典插入顺序从 3.7 起是语言保证。两种方式都要求元素可哈希,列表和普通字典不行;例如 set([[1], [1]]) 抛 TypeError,这类数据需先定义业务唯一键。Java HashSet 的顺序语义更接近 Python set,保序的 LinkedHashSet 不与之等价。
list(x) 收集可迭代对象的元素;set(x) 还要求元素可哈希;dict(x) 接受映射或每项恰有键、值两个元素的可迭代对象,如 dict([("a", 1)])。不同构造器不能任意互换。原记录的 _running_runs: set[asyncio.Task] 和 add_done_callback(...discard) 是集合管理任务的另一用途;持有任务、结束移除与异常回收见第 03 篇。
5. 默认值判断要区分“假”和“没有传”
None、False、数值零、空字符串和空容器都为假。Python 的 or、and 返回操作数本身,不像 Java 的 || 返回 boolean。真假值与布尔运算
因此 0 or 5 得到 5,user_input or "anonymous" 会把空字符串也替换掉。原记录 self._provider or AnthropicProvider(...) 用来选择依赖,便于替换模型实现或测试 Mock,但它判断的是真假,不只是“有没有传 provider”。有效对象若可能为假,就应显式判断 is None。
extra_handlers = extra_handlers or [] 用于只遍历内容的场景可能够用,却会把调用者传入的空列表换成另一个列表。第 1 节的函数还要修改调用者的列表,必须保留 if history is None 的判断。
6. f-string 在执行到表达式时求值
f"...{表达式}..." 会计算花括号里的变量、运算或函数调用,再把结果格式化进字符串;如 f"2 + 3 = {2 + 3}"、f"长度: {len('Alice')}"。下面把任务展示片段补成可运行的小例:
from types import SimpleNamespace
t = SimpleNamespace(status="pending", id=1, subject="读配置", blocked_by=[2, 3])
marker = {"pending": "[ ]", "in_progress": "[>]", "completed": "[x]"}
blocked = f" (blocked by: {t.blocked_by})" if t.blocked_by else ""
line = f"{marker.get(t.status, '[?]')} #{t.id}: {t.subject}{blocked}"
print(line) # [ ] #1: 读配置 (blocked by: [2, 3])
marker.get 找不到状态时返回 [?],blocked 决定是否附加依赖。它也能用字符串拼接或 % 格式化写出,f-string 通常更易对应文字与表达式。Java 可对照 String.format、MessageFormat.format 理解用途;字符串模板只在 JDK 21、22 预览,之后撤回,JDK 23 未包含,不能概括成“Java 21+ 都有”。Java 版本说明
f-string 从 Python 3.6 起支持;外双内单的引号方式在旧版也清楚,3.12 进一步允许表达式内重用外层引号等语法。f-string 语法
求值发生在执行到该表达式时。logger.debug(f"结果: {expensive()}") 即使 DEBUG 关闭也先调用函数;logger.debug("结果: %s", expensive()) 只延迟消息格式化,函数调用仍先执行。若要跳过昂贵计算,先判断 logger.isEnabledFor(logging.DEBUG)。logging 优化
7. 导入先看绑定,再看包入口
import math
from math import sqrt
import math as m
print(math.sqrt(9), sqrt(9), m.sqrt(9)) # 3.0 3.0 3.0
三种形式分别绑定模块名 math、取出的名字 sqrt、模块别名 m。点分导入 import mini_claude.core.tools 无别名时绑定顶层 mini_claude,访问需写完整路径;import mini_claude.core.tools as tools 才直接得到 tools 别名。from ...tools import BaseTool 只把所需名字绑定进当前作用域,不代表它一定能直接实例化。
from module import * 批量绑定名字,容易覆盖同名对象,也难追踪来源。普通模块定义了 __all__ 时按清单选取,否则通常取不以下划线开头的名字。__all__ 不是访问控制:未列入清单的名字,只要实际存在,仍可显式导入,包括 _internal_helper。它不能等同 Java 的 public 或包级访问权限,也不是每个包都必须写。
常规包的 __init__.py 可以为空,或聚合子模块的导出。以下是项目结构片段,前提是 tools/ 下已有对应模块和名字:
# tools/__init__.py
from .base import BaseTool, ToolResult
from .registry import ToolRegistry
from .invocation import invoke_tool
__all__ = ["BaseTool", "ToolResult", "ToolRegistry", "invoke_tool"]
这才使外部能从包入口导入 BaseTool,而不用每次写到 tools.base;Python 不会自动把所有子模块的类收集进包。清单中的名字须能由包提供,星号导入不会自动扫描整个目录。
正常首次加载常规包时执行 __init__.py,后续一般复用缓存;显式重新加载等另论。过程还可能加载上层包,不能说三种写法“都只执行同一个文件”。包入口宜保持轻量,避免网络请求或昂贵初始化造成意外副作用。Python 3.3 起支持可以没有 __init__.py 的命名空间包;“没有它一定不能导入”不成立。模块与包
Java 用 package 声明组织包,普通 import 引入类型短名,static import 引入静态成员,都不能直接等同 Python 的模块对象绑定。至于模块互相引用何时失败、注解为何影响导入顺序,留到第 02 篇继续。
系列目录 · MiniClaude:个人 Agent 早期版本学习笔记
- 1
- 2
- 3
- 4
- 5
- 6