MiniClaude:个人 Agent 早期版本学习笔记 · 6
06-工程实践与工具链盲区梳理
Mini06:从配置到提交,串起工程实践与工具链
第一次离开 IDE,在命令行里跑一个 Python 项目,卡住的常常不是业务逻辑:mini 命令从哪来?改了 TOML 为什么没生效?切换分支后笔记为什么还在?日志里的参数又为什么跟运行时一起变了?
这些问题可以放回一次完整的工作过程:确认工作目录,找到命令入口,加载配置,运行并留下记录,用测试缩小问题,最后提交能复现的改动。下面沿这条线,把原来分散的 32 个工程知识点串起来。
1 先确认工作目录 再找到命令入口
文件还在 不等于已经提交
开始改代码前,先看 git status --short --ignored。工作目录里的文件可以从三个角度辨认:
短格式的两列分别反映索引相对 HEAD、工作树相对索引的状态;一个文件也可能同时有已暂存和未暂存修改,显示 MM。所以别只靠红绿颜色判断。D 表示某一侧发生删除,稍后再处理它。Git 状态格式
git add 的核心是把当时选中的内容放进索引。首次 add 会让新文件进入跟踪范围;已经跟踪的文件再次修改,也要重新暂存想提交的内容。add 之后又改了文件,新的那部分不会自动进入原来的暂存快照;没有新变化就不必重复提交。git add
未跟踪的笔记不在当前提交快照里,切换到另一个没有同名文件的分支时,通常仍留在工作树。这解释了“笔记穿越分支”,却不是永久保护:如果目标分支要检出同名文件,Git 可能拒绝切换以防覆盖。已提交的笔记是否出现在另一个分支,要看那个分支的快照是否包含它,而不是看文件最早在哪个分支创建。git switch
.gitignore 只规定忽略哪些未跟踪路径,不会让已经跟踪的文件自动退库。它自己也是普通文件,提交后才受版本控制,不同分支可以有不同版本。保留临时笔记无需先移除忽略规则:被忽略和未忽略的本地文件都不是备份;需要长期保留,就选择明确的版本管理或备份方式。
常见规则是忽略真实 .env、.venv/、__pycache__/ 和 runs/,提交不含密钥的 .env.example。!important.log 可以否定前面的匹配规则,但父目录整体被排除时,不能只靠这一行重新包含里面的文件。个人规则可以放 .git/info/exclude;全局规则由 core.excludesFile 指定,并非随便建一个 ~/.gitignore_global 就自动生效;子目录里的 .gitignore 作用于该目录及其后代。gitignore 规则
安装包以后 命令才有入口
原笔记里的入口声明是:
# 历史 pyproject.toml 片段,不是一个可独立安装的完整项目。
[project.scripts]
mini = "mini_claude.cli.main:main"
冒号左边是模块,右边是函数。安装器安装项目时生成命令包装器,执行 mini 才会进入 main();只把这几行写进文件不会立刻得到命令。包装器位于所选 Python 环境的脚本目录,那个目录还得在 PATH 中。项目允许可编辑安装时可以用 python -m pip install -e .;采用 uv 且配置为可安装项目时可用 uv sync,再在对应环境中运行。命令找不到,先查安装环境和 PATH,别先改业务代码。Python 入口点规范、uv 项目安装条件
mini run --goal "读配置" 里,run 是子命令,--goal 是选项,后面的字符串是值。最小解析例子就能看清边界:
import argparse
parser = argparse.ArgumentParser(prog="mini")
commands = parser.add_subparsers(dest="command", required=True)
run = commands.add_parser("run")
run.add_argument("--goal", required=True)
core = commands.add_parser("core")
core_commands = core.add_subparsers(dest="core_command", required=True)
for name in ("start", "stop", "status"):
core_commands.add_parser(name)
args = parser.parse_args(["run", "--goal", "读配置"])
assert (args.command, args.goal) == ("run", "读配置")
assert parser.parse_args(["core", "status"]).core_command == "status"
add_parser 创建子命令的解析器,add_argument 定义输入;需要几层子命令,就在相应解析器下再建 subparsers。--version 也是选项,可以用 action="version" 实现打印版本并退出;以 -- 开头不代表它是下一级命令。解析器得到 Namespace,业务分发仍由程序完成,常见做法是检查 args.command 或通过 set_defaults 绑定处理函数。原笔记列出的 run/trace/ping/chat 是一级命令,core start/stop/status 才有第二层。argparse 子命令
2 配置要看最终值 不能只看读取顺序
三个名字相似的文件,负责的事情不同:
原示例曾列出 pydantic、python-dotenv、anthropic、textual、httpx 等依赖。这说明依赖放在哪一层,不是可以替代当前项目依赖文件的完整安装清单。
override=False 不覆盖已有环境变量,已有值即使是空字符串也算“存在”;override=True 则允许文件覆盖它们。二者都只是 dotenv 的合并策略,不是 Python 自带的配置优先级。上述全链条只有在程序最后执行环境覆盖时才成立,也不能推广成“所有项目的环境变量永远最高”。python-dotenv 行为
沿一项设置往下追,比背优先级更有用。原笔记的链路是 config.toml [trace].include_llm_payload → TraceConfig.include_llm_payload → TracingProvider(include_payload=...);MINI_TRACE_INCLUDE_LLM_PAYLOAD=false 是其环境覆盖名。在 Bash 等 POSIX 风格 shell 中,可以在单次命令前赋值来临时关闭记录,也可以先 export 再运行 uv run mini run --goal "...",但要先确认当前版本确实支持该映射。环境变量名不会自动绑定字段,字符串 "false" 也不能直接交给 bool(),因为非空字符串会得到 True。
嵌套配置适合用 dataclass 表达;每份配置有自己的子对象,用 default_factory,不要共享一个可变实例:
from dataclasses import dataclass, field
@dataclass
class TraceConfig:
include_llm_payload: bool = False # 本文演示值,不是历史默认值
@dataclass
class Config:
trace: TraceConfig = field(default_factory=TraceConfig)
a, b = Config(), Config()
a.trace.include_llm_payload = True
assert b.trace.include_llm_payload is False
原 MiniConfig 的记录实际列了两个顶层字段 host/port,以及七个子配置:logging、agent、llm、trace、permission、compaction、mcp;不是“八个”。历史示例中的 host/port 是 127.0.0.1/7437,LlmConfig 有 default_model="claude-sonnet-4-6" 和 router="static",没有 max_tokens;笔记另标注 S4 的 rule_based、S6 的 cost_budget。TraceConfig 记录了 enabled=True、include_llm_payload=True,以及由 mini_home() 拼出的 traces/daemon.jsonl。这些值保留作读旧笔记的对照,不作为现在的模型、端口或隐私默认建议。
dataclass 提供数据组织和生成方法,不会凭类型注解自动校验外部输入。旧加载器“拒绝未知 TOML key”的行为来自应用校验,不来自 TOML 格式或 dataclass 本身。来自文件、环境或网络的值仍要检查类型、范围、允许的字段;可以写显式校验,也可以采用配置好的 Pydantic 模型,不能只换个类名就假定所有检查齐全。dataclass 与默认工厂
3 跟一遍运行 再看边界为什么这样写
读源码先画骨架,再跟一条完整路径,最后按需深入。原笔记中的角色很清楚:AgentRunner 管一次 run 的生命周期,AgentLoop 推进思考与工具执行,LLMProvider 接模型,ToolRegistry 查工具,EventBus 分发事件,ExecutionContext 保存 messages 和状态。
把一次 mini run --goal ... 跟到 cmd_run → AgentRunner.run → AgentLoop.run,再看 provider.chat()、invoke_tool()、bus.publish() 和结果返回。原笔记把 goal 继续追到了 ExecutionContext.messages 的首条 user message。这里是帮助定位的简化路径,不是说这些调用只能按这个顺序各发生一次。先走完主线,再回头读 EventBus,才不容易在连续跳转中失去上下文。
复制的是哪一层
工具参数要写进事件,或者要给最后一个工具加缓存标记时,先问“接下来会改哪一层”。dict(existing) 是构造调用,对字典做浅拷贝;params: dict[str, object] 才是在注解位置使用类型。不能仅凭方括号下结论,因为 data[key] 也用方括号。
tools = [{"name": "read", "schema": {"type": "object"}}]
request_tools = list(tools) # 新列表,仍引用原来的字典
if request_tools: # 空列表也能处理
last = dict(request_tools[-1]) # 单独复制准备改的那一层
last["cache_control"] = {"type": "ephemeral"}
request_tools[-1] = last
assert "cache_control" not in tools[-1]
assert request_tools[-1]["schema"] is tools[-1]["schema"]
这样新增顶层字段不会污染原工具,但嵌套 schema 仍共享。直接改 list(tools)[0]["name"] 会改到原字典;dict(tool_call.input) 也不能冻结里面的嵌套列表或字典。需要稳定的历史记录,就在选定时点序列化,或按数据结构复制需要隔离的层。deepcopy 会递归复制,可能成本更高、复制过多,某些资源也不适合复制;它不是一律“太重不能用”。Python copy
for sub in list(self._subscriptions) 则是另一种目的:固定这次遍历的成员集合。原列表增删不会改变这份外层快照,但每个 Subscriber 仍是原对象,属性变化仍可见。Python 列表迭代按索引推进,边遍历边删除可能漏元素,追加可能让新元素也被访问,持续追加甚至无法结束;它既不是笼统的“行为未定义”,也不会像 Java 常见的 fail-fast 迭代器那样保证报错。快照同样不是线程安全机制。可变序列迭代语义
先把输入规范好 再进入内部逻辑
可选 extra_handlers 用 None 作默认值,避免 def f(items=[]) 在函数定义时只建一次列表、以后调用共享。默认参数求值进入函数后,如果内部会改列表,可以用 handlers = [] if extra_handlers is None else list(extra_handlers):既统一成列表,也不向调用者的原列表追加元素。
extra_handlers or [] 也能把 None 变成空列表,但会替换所有假值,并在传入非空列表时保留同一个对象。x or default 的范围是 None/False/0/""/[] 等,不只是 None;如果零或空集合是合法输入,要明确使用 is None。这就是“接口允许省略、内部规范化”的具体边界,不是见到空值就补一个默认值。Python 真值与短路运算
响应配对代码也在做边界处理。旧笔记记录的是先 msg.get("id"),再 if req_id and req_id in self._pending,随后 pop(req_id) 取出 Future,最后检查 not fut.done()。这几步分别防缺字段、无待办请求、重复完成;但 truthy 检查也会拒绝 0 和空串,不能解释成“只判断 None”。
下面明确选择“请求 ID 必须为非空字符串”的小型约定,把检查写完整:
def settle(pending, msg):
req_id = msg.get("id")
if not isinstance(req_id, str) or not req_id:
return False
fut = pending.pop(req_id, None) # 本例约定 pending 的值只能是 Future
if fut is None or fut.done():
return False
fut.set_result(msg)
return True
这是改良示例,不是把旧代码冒写成 pop(key, None)。pop(key) 删除并返回值,缺键会抛 KeyError;pop(key, default) 才有缺键回退;del d[key] 只删除、不返回值,缺键同样会报错。旧代码的成员检查只在检查与删除之间没有其他修改时才有效。
Future.done() 包括已得到结果、已设置异常、已取消。对这些 Future 再 set_result() 都会抛 InvalidStateError。重复响应可能找不到 pending 项,已取消的请求却可能仍有残留项,所以两层检查各有作用。这段代码按单个事件循环、检查到设置之间无 await 使用;asyncio.Future 本身不提供线程安全。Future 状态与 set_result
4 运行时既要留记录 也要把输出分清楚
为一次 run 准备目录时,path.mkdir(parents=True, exist_ok=True) 会补建缺失父目录,并容许目标已经是目录。重复执行得到同样的目录结构,是这里的幂等性;不是“任何情况下都不报错”。目标被普通文件占用、权限不足等仍会失败。Path.mkdir
原笔记用 runs/<run_id>/events.jsonl 作为运行记录:writer 订阅 EventBus 后,接收运行、步骤、工具调用及 LLM 相关事件,供回放、调试和分析。笔记列过 RunStarted/Finished、StepStarted/Finished、ToolCallStarted/Finished/Failed、LlmToken、LlmUsage 等,并统计为 24 种;这是当时的事件清单,不是本文确认过的当前总数,也不保证每种事件都成功持久化。
# 结构示意:EventWriter、run_path、bus 和 execute_run 由应用提供。
async def run_with_events(run_path, bus, execute_run):
run_path.mkdir(parents=True, exist_ok=True)
async with EventWriter(run_path / "events.jsonl") as writer:
writer.subscribe(bus)
await execute_run()
async with 等待 __aenter__();成功进入后,无论块正常结束还是抛异常,都会进入 __aexit__() 清理路径。至于是否打开、关闭文件,要看管理器实现。退出代码自己失败、进程被强杀或断电,都不能靠语法保证善后完成;它和 Java try-with-resources 的用途相近,但不是同一套接口。async with 语义
JSONL 是 UTF-8 文本,每行一个有效 JSON 值,事件通常选对象,一行结束再追加下一行。字符串内部换行要编码成 \n,不要把一条事件美化成多行。整体配置可以用单个 JSON 文档,逐条追加的事件适合 JSONL;这是一种使用选择,不代表普通 JSON 一定只能整份载入内存。JSON Lines 格式
还要分清三个层次:写入 Python 缓冲、flush() 推向底层、操作系统同步到存储。flush() 不能证明断电不丢数据;需要更强持久性时,得按要求安排 flush()、os.fsync() 和恢复策略。崩溃也可能留下不完整尾行。能按行读回的是已留下的记录,不是“格式选了 JSONL 就不怕中途崩”。Python fsync
只把订阅者关心的事件送过去
旧 IpcEventBroadcaster 笔记里,集合名是 _subscriptions,推送前同时过滤 topic 和 scope。topic 用 shell 风格通配符:* 任意长度、? 一个字符、[seq] 集合内一个字符、[!seq] 集合外一个字符。scope 为 global 时放行,为 run:<id> 时精确比较 run_id,其他值不匹配。
import fnmatch
def accepts(event_type, run_id, topics, scope):
topic_ok = any(fnmatch.fnmatchcase(event_type, t) for t in topics)
scope_ok = scope == "global" or (
scope.startswith("run:") and run_id == scope[4:]
)
return topic_ok and scope_ok
assert accepts("run.started", "r1", ["run.*"], "run:r1")
assert not accepts("tool.call_started", "r1", ["run.*"], "global")
assert not accepts("run.finished", "r2", ["run.*"], "run:r1")
原笔记用 fnmatch.fnmatch;本例改用 fnmatchcase,让事件名大小写规则不受操作系统影响。fnmatch 匹配的是字符串,/ 没有特殊地位,** 也不自动获得递归目录语义;不要把它与 glob 的路径遍历或 Ant 的 **/*.java 完全等同。fnmatch 文档
文本流与状态行切换时 要先收尾
终端里,连续两个文本片段应拼在一起,工具状态行却应从新行开始。原 StdoutPrinter.handle 接收 dict,通过 event.get("type") 分发;llm.token 分支取 event.get("token", ""),用 print(..., end="", flush=True) 追加,并设 _inline=True,不先换行。这个事件名是项目命名,不保证每个片段恰好等于一个模型 token。
step.started、tool.call_started 等结构化分支先调用 _ensure_newline():仅当 _inline 为真时打印换行并重置标志,再输出状态行。这样“你好”后跟工具状态,会得到两行,而不是 你好[tool] ...。原示例的 run.started 分支直接打印,没有调用 _ensure_newline(),不能把它概括成所有结构化分支都调用。若打印器会复用、末尾没有结束事件,是否要在 run 开始或关闭时补一次收尾,也应成为显式约定和测试。
5 用一个小测试 证明边界真的成立
验证不必一上来就真实调用 LLM。上面的 settle 已把响应配对缩成可独立测试的函数,先测正常响应、重复响应、取消和非法 ID,能比反复跑完整 Agent 更快定位问题。
下面把几个测试工具放在同一个可运行场景里。将它与前面的 settle 放在同一测试文件中,安装 pytest、pytest-asyncio 后,用 python -m pytest -q 运行:
import asyncio
from unittest.mock import Mock, AsyncMock
import pytest
@pytest.fixture
def provider():
stub = Mock()
stub.chat = AsyncMock(return_value={"id": "r1", "text": "ok"})
return stub
@pytest.mark.asyncio
async def test_reply_is_set_once(provider):
fut = asyncio.get_running_loop().create_future()
pending = {"r1": fut}
reply = await provider.chat("读配置")
assert settle(pending, reply) is True
assert settle(pending, reply) is False
assert (await fut)["text"] == "ok"
provider.chat.assert_awaited_once_with("读配置")
这里,pytest 发现并运行 test_...;assert 声明预期,失败时 pytest 展示表达式和值的差异;Mock 提供替代对象,AsyncMock 替代会被 await 的方法;@pytest.mark.asyncio 让插件运行异步测试;fixture 按参数名提供前置对象,默认每个测试重新创建,不是天然共享一个全局实例。熟悉 Java 的话,可以把 pytest、Mock 与 JUnit、Mockito 的用途对照,但不要逐项照搬生命周期。pytest 断言、fixture 生命周期、AsyncMock、pytest-asyncio
这个测试只证明本地配对逻辑和一次异步调用符合约定,没有证明真实模型、网络、权限、文件写入和整个 Agent 都可用。继续验证时,让问题决定测试:配置不生效,就测各来源的冲突值;怀疑浅拷贝,就同时断言外层隔离和嵌套共享;事件不显示,就分别检查 topic、scope、writer 和打印器,别用一个“运行成功”覆盖这些不同结论。
6 出问题时 顺着配置 日志和差异回查
先看程序实际拿到了什么配置,再按 run_id 看事件走到了哪一步,最后检查本次代码差异。日志应记录有用的状态和原因,避免为了调试把 API Key 或完整敏感 payload 打出来。
如果卡在读代码而不是运行,先把术语翻回动作:“注册”是让 bus 记住一个 handler;“订阅”是登记感兴趣的事件;“派发”是把事件交给处理者;“回调”是把函数交出去,等约定时机再被调用;“扇出”是一个事件交给多个接收者。是否依次执行、是否异步排队,仍要看实现。查清概念后,用自己的话写下来,遇到不懂及时追问,再回到主流程;允许理解逐次加深,不必第一遍钻完所有类。
常见缩写也要靠上下文核实:msg/message、req/request、resp/response、ctx/context、exc/exception、sub/subscriber、cb/callback。tc 在原笔记的 response.tool_calls 循环里指工具调用对象,记录的类型是 ToolCallBlock;它不是 Python 规定的缩写。短作用域可以用清楚的短名,跨函数、跨模块的名称宜更明确;拿不准就看赋值来源、类型和调用者。
写注释时补上代码看不出的意图或约束。例如 .append(handler) 上面重复“追加 handler”帮助不大;“保存订阅者,供后续 publish 调用”解释用途;“按注册顺序派发,避免处理顺序漂移”解释设计选择,但只有实现确实保证该顺序时才能这样写。实现细节在解释限制或取舍时也有价值,不必机械地全部删除。
看到 D 先查是哪一层删除
D 不能证明“频繁切分支让 Git 恢复失败”,也不能证明删除一定是意外。先用 git diff --name-status 看工作树,用 git diff --cached --name-status 看暂存区,再确认是否本来就要删除。只有确定要丢弃相应变化,才恢复指定路径:
# 假设 app.py 是项目里一个已跟踪文件;先检查,再择一操作。
git diff -- app.py
git diff --cached -- app.py
git restore -- app.py # 默认从索引恢复工作树;会丢弃该路径未暂存修改
# 若只是要取消暂存,使用下面这一条,而不是上面那条:
git restore --staged -- app.py # 默认从 HEAD 恢复索引,工作树内容不变
git restore . 会作用于当前目录下匹配的所有路径,不仅补回缺失文件,还可能抹掉其他未暂存修改。--staged 也不是“把修改搬回工作区”,而是改写索引、保留工作树。若删除已经暂存,单独恢复工作树未必能找回文件,应先检查 HEAD、索引和工作树的差别,再选来源。现代 Git 把 switch 与 restore 分开,比一条 checkout 同时负责切分支和恢复文件更容易读懂。git restore
整份文件都变了 先排除换行符
LF 是 \n,CRLF 是 \r\n。LF will be replaced by CRLF 表示当前规则可能在后续检出时转换换行,不等于提交失败,也不能一概说不影响功能:脚本、补丁和外部工具可能敏感。先查 git config --show-origin --get core.autocrlf,再看仓库的 .gitattributes;没有配置值不代表查询坏了。
Git 并非在所有安装中都默认开启 core.autocrlf。需要文本规范化时,可以由团队在 .gitattributes 中明确 *.py text eol=lf、*.bat text eol=crlf,把规则跟代码一起交接,而不是要求每个人盲目修改全局配置。core.autocrlf=true 是一种工作树使用 CRLF 的配置选择,不是所有 Windows 项目的必选项。调整规则后还要检查实际 diff,避免把整份换行变化夹进业务修复。Git 换行规范化
7 最后提交的是哪份内容
测试通过后,再检查要交出的内容。下例的文件名和分支名都是示例,不能直接当成当前项目清单:
git status --short
git diff
git add -- app.py test_app.py # 只暂存确认过的文件
git diff --cached # 这份差异才会进入下面的普通提交
git commit -m "修复重复响应处理" -m "保留首次结果,并补充回归测试"
git log --oneline --graph -5
git switch -c stage/next # 在当前提交上创建并切换到下一分支
-m 是 --message,让提交信息直接来自命令行;多个 -m 形成多个段落。也可以省略它打开编辑器,或通过 -F - 从标准输入读取多行信息。git log --oneline 用短提交标识加标题展示历史,-5 限制条数,--graph 展示提交拓扑。git commit、git log
git switch -c 与旧写法 git checkout -b 都能创建并切换分支。若本次改动应属于新分支,应在工作开始时建分支;上面的顺序适合“先把当前阶段提交好,再开始下一阶段”。git add . 会把范围内许多变化一起暂存,方便但要特别留意无关文件和未被忽略的秘密。
提交记录说明改了什么;配置来源说明怎样运行;测试说明验证了什么;事件记录说明一次执行发生了什么。把这几份证据对齐,才便于别人复现和继续修改。本地 commit 不是 push,更不是部署上线;真正发布还需要项目已有的检查和授权,不能从这组基础命令推导出“已经可上线”。
系列目录 · MiniClaude:个人 Agent 早期版本学习笔记
- 1
- 2
- 3
- 4
- 5
- 6