基础认知
建立模型输入、上下文和工程基础,让后续系统拥有清楚、可验证的起点。
模型接收什么信息,开发者如何建立可靠输入与工程基础?概念校准
上下文
- 它是什么
- 一次模型调用可见的信息集合,包括指令、对话、工具 Schema 与结果、检索证据和当前状态。
- 它不是什么
- 不只是聊天记录,也不等于模型能够永久记住的全部信息。
可观测性
- 它是什么
- 通过结构化日志、Trace、指标和版本信息解释系统在一次运行中发生了什么。
- 它不是什么
- 不只是保存终端输出;没有关联 ID、状态转换和敏感信息边界的日志难以诊断。
Python、Git、HTTP 与工程基础
搭建 Forge CLI、配置、Schema、HTTP、异步、日志和测试骨架。
Python 3.12 · pytest · Pydantic · SQLite · deterministic Mock概念校准\n\n在开始操作代码前,先把本章涉及的概念放回正确的工程边界。\n\n\n\n### 上下文 context\n\n- 它是什么: 一次模型调用实际可见的信息集合,包括指令、对话片段、工具 Schema 与结果、检索证据、当前状态和输出约束。\n- 它不是什么: 它不只是聊天记录,也不是数据库中的全部信息;只有经过选择并放入本次调用的信息才属于本次上下文。\n- 与相邻概念的关系: RAG 选择外部证据,Memory 保存跨步骤状态,Tool Schema 描述可用动作;三者都可供给上下文,但职责不同。\n- 在 HeatStack Forge 中的位置: Forge 的 context builder 按任务、权限、预算和来源优先级组装输入,并记录每段信息的来源与版本。\n- 典型误用与修正: 误用是把更多 Token 当成更完整的理解。修正方法是建立选择、截断、引用和敏感信息过滤规则,并用固定夹具验证。\n\n### 可观测性 observability\n\n- 它是什么: 通过结构化日志、Trace、指标、状态转换和版本信息解释一次运行发生了什么,并支持定位失败与比较版本。\n- 它不是什么: 它不只是终端输出,也不是无限记录所有输入;缺少关联 ID、状态转换或敏感信息边界的日志既难排查又可能泄露数据。\n- 与相邻概念的关系: Harness 产生运行事件,Agent Loop 与工具调用形成 Trace,Evaluation 聚合指标,Memory 与恢复依赖版本记录。\n- 在 HeatStack Forge 中的位置: Forge 为每次任务生成 operation_id,记录阶段、工具、耗时、成本、错误类别、状态变化和证据路径,同时过滤敏感信息。\n- 典型误用与修正: 误用是等失败后再临时加日志。修正方法是在合同设计时定义事件与关联字段,并用故障注入确认能重建时间线。\n\n
建立可导入、可测试且把源代码、测试和配置分开的 Forge 项目骨架
先看一个具体问题
为什么普通目录不能作为 Python 包导入?
你接手了一个名为 forge 的目录,里面暂时只有空的 init.py 文件,还没有 project_layout.py。
测试文件 tests/test_stage.py 尝试从 forge.project_layout 导入 inspect_layout 和 is_importable_package,但当前目录缺少 project_layout.py,导致导入失败。
你需要创建 forge/project_layout.py,实现两个函数:inspect_layout 检查项目必需路径并返回缺失路径列表,is_importable_package 判断 forge 目录是否是一个真正的 Python 包。
关键决策在于:判断一个目录是否为包,不能只看目录是否存在,而必须检查包标记文件 init.py 是否存在。
先做判断
在实现 is_importable_package 时,以下哪种检查能正确判断 forge 目录是一个可导入的 Python 包?
- 检查 (root / “forge”).is_dir() 是否为真
- 检查 (root / “forge” / “init.py”).is_file() 是否为真
- 检查 (root / “forge”).exists() 是否为真
- 检查 (root / “forge”).iterdir() 是否非空
判断依据: 正确答案是检查 init.py 文件是否存在。Python 包必须包含 init.py 文件,仅目录存在不足以让 import 语句成功。
它是什么
可导入包的本质
Python 包是一个包含 init.py 文件的目录,该文件作为包的初始化入口,使目录能被 import 语句识别。
inspect_layout 函数通过检查一组必需路径是否存在来验证项目结构,返回缺失路径的列表,帮助开发者快速定位问题。
它不是什么
常见误解
普通目录即使名称与包名相同,也不能作为包导入,因为 Python 解释器不会将其视为模块。
inspect_layout 返回的列表不是错误信息,而是缺失路径的集合,空列表表示所有必需路径都存在。
它与相邻概念的关系
函数之间的关系
is_importable_package 只关注 forge 目录是否为包,而 inspect_layout 检查整个项目的必需路径,包括包标记文件、测试目录和配置文件。
两者都基于路径存在性检查,但 is_importable_package 更具体地验证包标记文件,而 inspect_layout 提供更全面的项目结构验证。
本阶段的边界决定: 判断一个目录是否为 Python 包时,必须检查 init.py 文件是否存在,而不能仅检查目录本身是否存在。
从显式允许列表加载环境配置,并在错误和诊断输出中遮蔽密钥
先看一个具体问题
公开配置泄露 API 密钥
在 forge/config.py 中,public_config 函数直接使用 dataclasses.asdict 返回内部配置字典,导致 api_key 的原始值出现在诊断输出中。
测试 test_public_config_never_contains_api_key 断言 repr(shown) 不包含 secret-value,并且 shown["api_key"] 等于 [redacted]。
当前实现无法通过该测试,因为 asdict 会原样复制所有字段,包括敏感字段。
你需要修改 public_config,使其返回一个显式构造的字典,其中 api_key 被替换为遮蔽标记。
先做判断
如果直接使用 dataclasses.asdict(config) 生成公开配置,会发生什么?
- 公开配置会包含原始 API 密钥,导致密钥泄露。
- 公开配置会自动遮蔽 API 密钥,无需额外处理。
- 公开配置会抛出异常,因为
asdict不支持冻结数据类。 - 公开配置会忽略
api_key字段,只返回其他字段。
判断依据: 正确答案是第一个选项。asdict 会递归地将数据类转换为字典,并保留所有字段的原始值,因此 api_key 会以明文形式出现在公开配置中。
它是什么
公开配置是什么
公开配置是供诊断、日志或用户界面展示的配置视图,它必须隐藏敏感信息。
它通过显式构造字典来精确控制哪些字段被暴露以及如何转换。
在本阶段,公开配置包含 api_base、model 和遮蔽后的 api_key。
它不是什么
公开配置不是什么
它不是内部配置的完整副本,不能直接使用 asdict 生成。
它不是用于实际 API 调用的配置,实际调用仍使用内部 Config 对象。
它不负责验证配置的完整性,验证由 load_config 完成。
它与相邻概念的关系
与其他组件的关系
load_config 从环境字典中读取必需变量并构造内部 Config 对象。
public_config 接收内部 Config 对象并返回安全的公开字典。
测试通过 load_config 创建配置,然后调用 public_config 验证遮蔽行为。
本阶段的边界决定: 公开配置必须显式构造,敏感字段要用占位符替换;内部配置与诊断输出必须分离。
把外部字典解析为带范围校验的请求合同,并拒绝未知场景
先看一个具体问题
外部字典可能包含未知场景或越界步数
当前 forge/contracts_v2.py 尚未创建,测试文件 tests/test_stage.py 中的 test_unknown_scenario_is_rejected 和 test_step_budget_has_a_bounded_range 会因导入失败而报错。
你需要实现 ForgeRequest.parse,它接收外部字典并返回一个 ForgeRequest 实例,但必须拒绝未知场景(如 finance)和超出范围的 max_steps(如 100)。
如果只做类型转换,finance 会被当作合法场景,max_steps=100 也会被接受,导致后续流程可能使用无效配置。
因此,解析时必须显式检查场景是否属于允许集合,并验证 max_steps 是否在 1 到 20 之间。
先做判断
如果 ForgeRequest.parse 只使用 str() 和 int() 转换输入,会发生什么?
- 未知场景会被拒绝,因为类型转换会自动验证。
- 未知场景会被接受,因为类型转换不检查语义。
max_steps会自动限制在 1 到 20 之间。- 解析会抛出
ValueError,因为字典键缺失。
判断依据: 正确答案是第二个选项:类型转换只保证类型正确,不检查值是否属于允许集合或范围。因此 finance 会被接受,max_steps=100 也会被接受。
它是什么
请求合同是带语义验证的解析器
ForgeRequest.parse 是一个类方法,它接收外部字典,提取并转换字段,然后执行语义验证:场景必须属于 ALLOWED_SCENARIOS,请求长度至少 8 个字符,max_steps 必须在 1 到 20 之间。
验证通过后才构造并返回不可变的 ForgeRequest 实例,确保后续代码可以安全使用这些字段。
它不是什么
类型转换不等于验证
str() 和 int() 只负责类型转换,它们不会检查值是否合法。例如,str('finance') 返回 'finance',但 'finance' 不在允许集合中。
同样,int('100') 返回 100,但 100 超出了 max_steps 的合法范围。因此,必须显式编写检查逻辑。
它与相邻概念的关系
解析、验证与构造的关系
解析过程分为三步:首先从字典中提取原始值并转换为期望类型,然后执行语义验证,最后构造数据类实例。
验证失败时抛出 ValueError,阻止无效数据进入系统;验证通过后,构造的实例是不可变的,保证后续使用的一致性。
本阶段的边界决定: 当输入字典缺少字段或字段类型错误时,parse 应该抛出异常还是使用默认值?本阶段选择使用默认值(如空字符串或 0),但随后验证会拒绝这些默认值,从而确保只有有效请求才能通过。
通过可替换 Transport 发送 JSON 请求,并显式处理状态码和响应类型
先看一个具体问题
HTTP 客户端必须拒绝非成功状态码和非对象响应体
当前 forge/http_client.py 文件尚不存在,测试 test_non_success_status_is_not_treated_as_data 和 test_transport_receives_timeout_and_normalized_url 在收集阶段就会因 ModuleNotFoundError 失败。
你需要从零创建 ApiClient 和 Response,并让 create_plan 在收到 429 状态码时抛出包含 ‘429’ 的 RuntimeError,而不是把错误响应体当作正常数据返回。
同时,create_plan 必须把 base_url 末尾的斜杠去掉后拼接 /plans,并把构造时传入的 timeout 原样传给 transport.post。
先做判断
如果 create_plan 直接返回 response.body,当服务器返回 429 限流响应时会发生什么?
- 调用方会拿到一个错误字典,但无法区分这是成功数据还是错误信息。
ApiClient会自动抛出异常,因为 HTTP 库会处理状态码。- 程序会崩溃,因为
response.body不是字典。 transport.post会重试直到成功。
判断依据: 正确选项是第一个:直接返回 response.body 会把错误响应体当作正常数据交给调用方,调用方无法从返回值判断请求是否成功。第二个选项错误,因为 ApiClient 使用的是自定义 Transport 协议,不会自动检查状态码。第三个选项错误,因为 response.body 可以是任意对象,不一定是字典。第四个选项错误,因为 Transport 协议没有定义重试行为。
它是什么
传输成功与应用层验证是分离的
Transport 协议只负责把请求发送到网络并返回一个 Response 对象,它不关心状态码的含义。
ApiClient 在拿到 Response 后必须自己检查 status 是否在 200 到 299 之间,并确认 body 是字典,然后才能把数据交给上层业务逻辑。
它不是什么
不是任何 HTTP 响应都能直接作为数据返回
非 2xx 状态码表示请求没有成功,响应体通常是错误信息而不是业务数据,直接返回会让调用方误以为操作成功。
响应体不是字典时,上层代码无法安全地按字段访问数据,必须提前拒绝,避免后续出现 AttributeError 或类型错误。
它与相邻概念的关系
Transport、Response 和 ApiClient 的协作关系
ApiClient 依赖 Transport 协议发送请求,Transport 返回 Response,Response 包含 status 和 body 两个字段。
ApiClient.create_plan 负责把业务负载 payload 和超时时间传给 transport.post,然后对返回的 Response 做状态码和类型检查,最后才返回 body。
本阶段的边界决定: 当 response.status 不在 200 到 299 之间时,必须抛出 RuntimeError 并包含状态码;当 response.body 不是字典时,必须抛出 ValueError。只有两个检查都通过,才能返回 response.body。
并发执行独立读取任务,同时限制在途数量并保持结果顺序
先看一个具体问题
为什么 gather 会同时启动所有任务?
在 forge/concurrency.py 中,你被要求实现 bounded_map 函数,它接收一个可迭代对象、一个异步 worker 函数和一个并发上限 limit,并返回与输入顺序一致的结果列表。
当前起始代码中该文件不存在,因此测试在导入 forge.concurrency 时直接失败,报错 ModuleNotFoundError: No module named 'forge.concurrency'。
即使你创建了文件并天真地使用 asyncio.gather 直接启动所有 worker,测试 test_in_flight_work_never_exceeds_limit 也会失败,因为峰值并发会达到 6 而不是期望的 2。
你需要理解 asyncio.gather 本身不会限制并发,必须显式使用 asyncio.Semaphore 来约束同时运行的任务数量。
先做判断
如果直接使用 asyncio.gather(*(worker(item) for item in items)) 而不加任何限制,当 limit=2 且输入有 6 个元素时,峰值并发会是多少?
- 2,因为 limit 参数会自动生效
- 6,因为 gather 会同时启动所有协程
- 1,因为 asyncio 默认串行执行
- 不确定,取决于事件循环调度
判断依据: 正确答案是 6。asyncio.gather 会立即创建所有协程并同时调度它们,它不会读取或应用任何 limit 参数。只有显式使用 asyncio.Semaphore 才能限制同时运行的任务数。
它是什么
有界并发是什么
有界并发是一种控制模式:你有一组独立任务,但只允许最多 N 个任务同时执行,其余任务必须等待。
在 asyncio 中,asyncio.Semaphore 是一个计数器,初始值为 N;每个任务在开始前通过 async with semaphore 获取一个许可,如果许可已用完则挂起等待,任务完成后释放许可。
bounded_map 将每个 worker 调用包装在一个受信号量保护的协程中,然后使用 asyncio.gather 收集所有包装后的协程,从而既保持结果顺序又限制并发。
它不是什么
有界并发不是什么
它不是 asyncio.gather 的内置功能:gather 只负责并发调度和结果聚合,不提供任何并发数量限制。
它也不是简单的串行执行:串行执行一次只运行一个任务,而有界并发允许最多 N 个任务同时运行,从而在资源利用和系统压力之间取得平衡。
它更不是通过 asyncio.sleep 或手动计数来实现的临时方案;正确的做法是使用标准库中的同步原语 asyncio.Semaphore。
它与相邻概念的关系
与其他概念的关系
asyncio.Semaphore 与 asyncio.Lock 类似,但 Lock 只允许一个持有者,而 Semaphore 允许多个持有者,数量由初始值决定。
bounded_map 与 asyncio.gather 的关系是:gather 负责并发执行所有包装后的协程,而每个包装协程内部通过信号量控制实际 worker 的启动时机。
结果顺序由 gather 保证:它按照传入协程的顺序收集返回值,因此即使任务完成顺序不同,最终列表顺序与输入顺序一致。
本阶段的边界决定: 当需要并发执行多个异步任务但系统资源有限时,必须使用 asyncio.Semaphore 显式限制并发数;如果任务数量很少且资源充足,可以直接使用 asyncio.gather 而不加限制。
生成可分类的结构化事件,并递归遮蔽嵌套敏感字段
先看一个具体问题
日志事件中的嵌套敏感信息泄露
在 forge 系统中,当 provider 调用失败时,我们需要记录一个结构化事件,其中包含操作 ID、错误类别和详细信息。
详细信息可能包含嵌套的字典和列表,例如请求头中的 authorization 字段或列表项中的 token 字段。
如果只遮蔽顶层字典中的敏感键,嵌套的敏感值仍然会出现在日志中,导致密钥泄露。
当前测试 test_nested_secrets_are_redacted 要求嵌套的 authorization 和 token 值被替换为 [redacted],但起始代码尚未实现递归遮蔽。
先做判断
在实现 redact 函数时,如果只处理顶层字典,嵌套的敏感字段会发生什么?
- 嵌套敏感字段会被自动遮蔽,因为顶层字典已经处理了。
- 嵌套敏感字段会原样保留,导致泄露。
- 嵌套敏感字段会被删除,不会出现在结果中。
- 程序会抛出异常,因为无法处理嵌套结构。
判断依据: 正确答案是第二个选项:嵌套敏感字段会原样保留。因为 redact 函数只遍历了顶层字典的键值对,对于值仍然是字典或列表的情况没有递归处理,所以嵌套的敏感键不会被识别和遮蔽。
它是什么
递归遮蔽是什么
递归遮蔽是一种遍历任意嵌套数据结构(字典和列表)的方法,对每一层中的敏感键值进行替换。
它确保无论敏感信息位于数据结构的哪个深度,都会被替换为 [redacted],从而防止泄露。
递归遮蔽的核心是函数调用自身来处理子结构,直到遇到基本类型(如字符串、数字)为止。
它不是什么
递归遮蔽不是什么
递归遮蔽不是只检查顶层字典的键,也不是简单地对整个数据结构进行字符串替换。
它不是删除敏感字段,而是保留键名并将值替换为占位符,以维持事件结构的完整性。
它也不是对特定键名进行硬编码的浅层检查,而是基于一个敏感键集合进行通用处理。
它与相邻概念的关系
与其他概念的关系
递归遮蔽与数据结构遍历密切相关:字典需要遍历键值对,列表需要遍历元素。
它与事件生成函数 event 配合,event 调用 redact 来处理 details 字段,确保输出事件不包含敏感信息。
它与测试 test_nested_secrets_are_redacted 直接相关,该测试验证嵌套敏感值是否被正确遮蔽。
本阶段的边界决定: 递归遮蔽的边界是:只处理字典和列表两种容器类型,对于其他类型(如字符串、数字、布尔值)直接返回原值。敏感键的判断基于 SECRET_KEYS 集合,且键名比较时忽略大小写。
把提交身份、测试结果和工作区状态组合成可解释的发布门
先看一个具体问题
为什么测试全绿仍可能发布失败?
你正在为 forge 项目实现发布门。当前 forge/release_gate.py 尚未创建,测试 test_release_requires_commit_and_clean_generated_artifacts 和 test_passing_release_has_no_reasons 会因导入错误而失败。
发布门必须同时检查提交标识、测试结果和生成文件。如果只检查测试是否通过,一个没有提交 SHA 且包含 debug.log 的发布会被错误接受。
你需要实现 evaluate_release 函数,使其返回 (passed, reasons),其中 passed 为布尔值,reasons 为字符串列表。当提交 SHA 长度小于 7、存在失败检查或存在 .pyc/.log 生成文件时,passed 必须为 False 并给出具体原因。
先做判断
在实现 evaluate_release 之前,请预测:如果只检查所有测试是否通过,而忽略提交 SHA 和生成文件,会发生什么?
- 发布门会正确拒绝所有不干净的发布。
- 发布门会错误接受没有提交 SHA 且包含 debug.log 的发布。
- 发布门会崩溃,因为无法处理空提交 SHA。
- 发布门会忽略测试结果,只检查生成文件。
判断依据: 正确答案是第二个选项。只检查测试通过会忽略提交身份和工作区卫生,导致不可追溯的脏发布被接受。这正是故障夹具中注入的缺陷。
它是什么
发布门是什么
发布门是一个函数,它汇总多个检查结果并决定是否允许发布。它接收提交 SHA、检查结果列表和变更文件列表,返回一个布尔值和原因列表。
它必须同时验证提交身份、测试结果和工作区卫生。缺少任何一项都会阻止发布,即使所有测试都通过。
它不是什么
发布门不是什么
发布门不是简单的测试通过检查器。它不能只返回 all(check.passed),因为这样会忽略提交身份和生成文件。
它也不是一个日志系统或构建工具。它只负责评估发布条件,不负责执行测试或清理文件。
它与相邻概念的关系
与其他组件的关系
发布门使用 CheckResult 数据类来表示每个检查的结果。CheckResult 包含 name、passed 和 detail 字段。
发布门与测试文件 tests/test_stage.py 直接相关。测试文件定义了发布门必须满足的契约,包括对提交 SHA 和生成文件的检查。
本阶段的边界决定: 发布门必须检查提交标识、测试结果和生成文件;缺少任何一项都会阻止发布。
完成本章
完成可运行的 forge CLI、配置系统、日志和测试基线。
本地实验自检
- 未开始
- 2阅读中
- 3实验已下载
- 4测试结果已读取
- 5本地自检通过
verification.json 只在当前浏览器中解析,不会上传。这里验证的是实验合同,不是服务器认证或第三方背书。
概念校准与一周复习
三道题检查你是否掌握了本章边界、交付证据和恢复方法。答案只保存在当前浏览器。