← 工程跃迁 / 第 04 章
架构定位

基础认知

建立模型输入、上下文和工程基础,让后续系统拥有清楚、可验证的起点。

模型接收什么信息,开发者如何建立可靠输入与工程基础?

概念校准

上下文

它是什么
一次模型调用可见的信息集合,包括指令、对话、工具 Schema 与结果、检索证据和当前状态。
它不是什么
不只是聊天记录,也不等于模型能够永久记住的全部信息。

模型运行原理与多模态输入

理解 Token、上下文、Embedding 和视觉输入,并构建统一输入适配层。

本章只做一件事让三个场景的文本、图片和结构化文件进入同一输入合同。
开始前,Forge 已经具备已完成 Module 03 的“Python、Git、HTTP 与工程基础”,其通过验收的 solution 是本章起点。
完成后,Forge 将能够让三个场景的文本、图片和结构化文件进入同一输入合同。
卡住时的最小恢复点先只解析文本和文件元数据,对不支持的媒体给出明确拒绝。
下载本章实验仓库Python 3.12 · pytest · Pydantic · SQLite · deterministic Mock
阶段能力链
01用可替换编码器估算消息 Token,并为系统指令和输出保留预算02在不可丢弃约束和可选材料之间分配上下文预算,超限时明确拒绝03计算余弦相似度并拒绝维度不一致或零向量,避免把 Embedding 当答案04规范化 UTF-8 文本、换行和大小限制,同时保留来源元数据05依据真实文件签名和像素上限验证 PNG/JPEG,而不是只相信扩展名06把 JSON、CSV 和 Markdown 解析成带来源定位的块,并对格式错误给出明确失败07根据模型能力和媒体类型路由输入,并对不支持组合返回可解释拒绝
每一步只增加一种可验证能力;后一步建立在前一步已经通过的代码和测试上。

概念校准\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

STAGE 01

用可替换编码器估算消息 Token,并为系统指令和输出保留预算

先看一个具体问题

为什么字符数不能替代 Token 数?

在构建多模态输入管道时,我们需要估算消息列表将消耗多少 Token,以便为系统指令和输出预留预算。

如果直接使用字符长度来估算,就会把“hello model”这样的短语算成 11 个 Token,而实际分词器可能只产生 2 个 Token。

这种混淆会导致预算严重失真:短词多字符的消息被高估,而长词少字符的消息被低估。

本阶段要求你实现 estimate_tokens 函数,它必须调用传入的编码器来获得每个消息的 Token 序列,而不是依赖字符数。

先做判断

假设你有一个消息列表 ["hello model", "short"],使用 whitespace_encoder 编码,每条消息固定开销为 2。如果 estimate_tokens 错误地使用字符长度而不是编码器,结果会是多少?

  • 7(正确使用编码器)
  • 20(错误使用字符长度)
  • 5(只计算单词数)
  • 无法确定

判断依据: 正确答案是 20。字符长度计算为 len("hello model") + len("short") + 2*2 = 11 + 5 + 4 = 20。而正确使用编码器时,whitespace_encoder 将每个消息按空格分割,"hello model" 得到 2 个 Token,"short" 得到 1 个 Token,加上每条消息开销 2,总数为 2+2+1+2=7。

它是什么

Token 估算是什么

Token 估算是一个函数,它接收消息列表、编码器和每条消息的固定开销,返回总 Token 数。

编码器负责将文本转换为 Token ID 列表,估算函数只关心列表长度,不关心具体 ID 值。

每条消息的固定开销模拟了消息包装格式(如角色标记、分隔符)带来的额外 Token 消耗。

它不是什么

Token 估算不是什么

Token 估算不是字符计数:字符数忽略了分词规则,例如一个单词可能对应多个 Token,多个单词也可能合并为一个 Token。

它也不是精确的模型调用成本计算:真实模型还有注意力缓存、批处理等开销,这里只做线性估算。

它不负责选择编码器:编码器由调用方传入,估算函数必须保持可替换性。

它与相邻概念的关系

与其他概念的关系

编码器是 Token 估算的依赖:没有编码器,估算函数无法知道文本如何被切分。

每条消息开销是独立的加法项:它不参与编码,但影响总预算。

负开销校验是边界条件:如果开销为负,总 Token 数可能被低估,因此必须拒绝。

本阶段的边界决定:per_message_overhead 为负数时,估算结果可能小于实际编码 Token 数,因此必须抛出 ValueError 并包含 “non-negative” 字样。

STAGE 02

在不可丢弃约束和可选材料之间分配上下文预算,超限时明确拒绝

先看一个具体问题

有限窗口下,可选材料挤占必需指令

你正在为多模态输入模块实现上下文选择函数 select_context。当前代码把整个窗口当作可自由分配的容量,按优先级从高到低填充所有条目,没有区分必需项和可选项,也没有为模型输出预留空间。

在测试 test_output_reserve_and_required_context_are_protected 中,传入 system(60 tokens,必需)和 notes(30 tokens,可选,优先级更高),窗口 100,输出预留 20。正确结果应只返回 ["system"],因为扣除输出预留后可用预算为 80,必需项 system 占 60,剩余 20 不足以容纳 notes

但当前实现会先放入优先级更高的 notes,再放入 system,返回 ['notes', 'system'],导致必需的系统指令被可选笔记挤占,且没有保护输出空间。

另一个测试 test_impossible_required_context_is_rejected 要求当必需项本身超过预算时抛出 ValueError,而不是静默返回部分结果。

先做判断

select_context 中,如果窗口为 100、输出预留为 20,且有一个必需项占 90 tokens,函数应该怎样处理?

  • 返回该必需项,忽略输出预留
  • 抛出异常,因为必需项超过可用预算
  • 返回空列表,因为无法满足
  • 先放入其他可选项,再尝试放入必需项

判断依据: 正确做法是抛出异常。必需项是系统指令或策略等不可丢弃的内容,如果它们本身超过扣除输出预留后的预算,任何部分结果都会破坏系统约束,因此必须立即拒绝请求。

它是什么

上下文预算分配模型

上下文预算分配是一个两阶段过程:首先从总窗口中扣除输出预留,得到可用于输入内容的预算;然后先无条件纳入所有必需项,再按优先级从高到低填充可选项,直到剩余容量不足。

必需项是系统提示、策略文档等不可丢弃的约束,它们必须全部进入上下文,否则模型行为可能违反规则。可选项是检索到的参考材料、历史对话等,它们可以按优先级取舍。

它不是什么

不是简单的优先级排序

上下文预算分配不是把所有条目按优先级排序后依次放入,因为这样做会让高优先级的可选项挤占低优先级的必需项。

它也不是把整个窗口都用于输入内容,必须为模型输出预留空间,否则模型可能没有足够 token 来生成回答。

它与相邻概念的关系

与 token 估算和上下文概念的关系

本阶段依赖 Stage 01 的 token 估算函数 estimate_tokens 来获得每个条目的 token 数,但 select_context 本身只接收已经计算好的 token 数。

上下文预算分配是上下文概念的具体实现:它决定了哪些信息进入本次模型调用,体现了“上下文是经过选择的信息集合”这一边界。

本阶段的边界决定: 当必需项的总 token 数超过扣除输出预留后的预算时,必须抛出异常拒绝请求,而不是返回部分结果或静默丢弃必需项。

STAGE 03

计算余弦相似度并拒绝维度不一致或零向量,避免把 Embedding 当答案

先看一个具体问题

为什么点积不能直接作为向量相似度的度量?

在上一阶段,你已经实现了 forge/tokens.pyforge/context_budget.py,现在需要新增 forge/embeddings.py 来计算向量相似度。

如果直接返回两个向量的点积,相似度会受向量长度影响:例如 [1,0][10,0] 方向相同,但点积为 10,而不是期望的 1。

测试 test_cosine_similarity_is_scale_invariant 要求 cosine_similarity([1,0],[10,0]) 返回 1.0,而 test_dimension_mismatch_is_rejected 要求维度不一致时抛出 ValueError

你需要实现余弦相似度:先检查维度一致且向量非零,再计算点积并除以两个范数的乘积,从而消除长度影响。

先做判断

在实现 cosine_similarity 之前,请预测:如果函数只返回两个向量的点积,test_cosine_similarity_is_scale_invariant 会得到什么结果?

  • 返回 1.0,测试通过
  • 返回 10,测试失败
  • 抛出 ValueError
  • 返回 0

判断依据: 点积 [1,0]·[10,0] = 1*10 + 0*0 = 10,而测试期望 1.0,因此测试会失败。这说明点积受向量长度影响,不能直接作为相似度。

它是什么

余弦相似度是什么

余弦相似度通过归一化消除向量长度影响,只衡量方向一致性:它等于两个向量夹角的余弦值,范围在 -1 到 1 之间。

计算步骤:先检查两个向量维度相同且非零,然后计算点积,再除以两个向量范数的乘积。

对于方向相同的向量,无论长度如何,余弦相似度都是 1;对于正交向量,相似度为 0;对于方向相反的向量,相似度为 -1。

它不是什么

余弦相似度不是什么

余弦相似度不是点积:点积会受向量长度影响,而余弦相似度通过除以范数消除了这种影响。

余弦相似度也不是距离度量:它只关注方向,不关注向量之间的绝对距离。

余弦相似度不能处理零向量或维度不一致的向量,这些情况没有定义,必须拒绝。

它与相邻概念的关系

与其他概念的关系

余弦相似度与点积的关系:余弦相似度 = 点积 / (范数1 * 范数2),因此点积是余弦相似度的分子部分。

余弦相似度与范数的关系:范数用于归一化,确保相似度不受向量缩放影响。

余弦相似度与 Embedding 的关系:Embedding 是向量表示,余弦相似度用于比较两个 Embedding 的方向相似性,但 Embedding 本身不是答案。

本阶段的边界决定: 当向量维度不一致或任一向量为零向量时,余弦相似度没有定义,必须抛出 ValueError;否则计算点积并除以范数乘积。

STAGE 04

规范化 UTF-8 文本、换行和大小限制,同时保留来源元数据

先看一个具体问题

为什么必须拒绝无效 UTF-8 而不是静默丢弃?

在办公研究场景中,你从多个来源收集文档:网页、表格、PDF 导出文件。这些文件可能包含损坏的字节序列,例如一个孤立的 0xFF 字节。如果解析器静默丢弃这些字节,文档内容就会在不知不觉中改变,导致后续引用或分析基于不完整的证据。

当前 forge/text_input.py 文件尚未创建,测试 test_invalid_utf8_is_rejected_not_silently_droppedtest_line_endings_are_normalized 会因导入错误而失败。你需要实现一个 parse_text 函数,它必须严格验证 UTF-8 编码,并在遇到无效字节时抛出 ValueError,而不是忽略错误继续处理。

同时,不同操作系统使用不同的换行符:Windows 使用 CRLF(\r\n),旧 Mac 使用 CR(\r),Unix 使用 LF(\n)。为了后续处理的一致性,parse_text 必须将所有换行符规范化为 LF。

先做判断

如果 parse_text 使用 errors='ignore' 解码一个包含无效字节的文档,会发生什么?

  • 无效字节被丢弃,文档被接受,但内容可能不完整
  • 抛出 UnicodeDecodeError,文档被拒绝
  • 无效字节被替换为 U+FFFD,文档被接受
  • 程序崩溃,无法继续执行

判断依据: 选择第一个选项:errors='ignore' 会静默丢弃无效字节,导致文档被接受但内容不完整。这正是测试 test_invalid_utf8_is_rejected_not_silently_dropped 要防止的行为。

它是什么

文本输入规范化是什么

文本输入规范化是将外部字节流转换为内部统一表示的过程,包括严格解码 UTF-8、统一换行符、检查大小限制,并保留来源元数据。

严格解码意味着任何无效字节都会导致显式错误,确保输入内容与原始字节完全一致,不丢失任何信息。

它不是什么

文本输入规范化不是什么

它不是一个宽松的清理过程,不能静默丢弃或替换无效字节,因为这会改变证据的完整性。

它也不是简单的字符串替换,而是需要明确区分编码错误和换行符差异,并对编码错误采取拒绝策略。

它与相邻概念的关系

与其他组件的关系

parse_text 返回的 TextDocument 对象包含 sourcetext 字段,后续的 token 计数、上下文预算和嵌入生成都依赖这个规范化后的文本。

如果文本在入口处被静默修改,所有下游处理都会基于错误的数据,因此严格验证是保证整个管道可靠性的基础。

本阶段的边界决定: 当遇到无效 UTF-8 字节时,必须抛出 ValueError 并拒绝输入,而不是尝试修复或忽略,因为任何字节丢失都可能改变文档含义。

STAGE 05

依据真实文件签名和像素上限验证 PNG/JPEG,而不是只相信扩展名

先看一个具体问题

为什么不能只相信文件扩展名?

你正在为多模态输入模块添加图片支持。测试 test_extension_cannot_override_file_signature 要求:即使文件名是 fake.png,只要内容不是真正的 PNG 签名,validate_image 就必须抛出包含 signatureValueError

当前 forge/image_input.py 尚未创建,运行 python -m pytest -q tests/test_stage.py 会因 ModuleNotFoundError: No module named 'forge.image_input' 而收集失败。

你需要实现 validate_image,它接收文件名、原始字节、宽、高和可选的最大像素数,返回 ImageAsset。关键决策是:媒体类型由文件内容签名决定,而不是由扩展名决定。

先做判断

如果 validate_image 只根据文件名后缀判断媒体类型,当传入 fake.png 和普通文本字节时会发生什么?

  • 函数会返回 ImageAsset('image/png', ...),测试失败。
  • 函数会抛出 ValueError,测试通过。
  • 函数会返回 ImageAsset('image/jpeg', ...)
  • 函数会崩溃,因为无法解析文本。

判断依据: 正确答案是第一个选项。只根据扩展名判断会信任用户可控的元数据,导致伪造的 .png 文件被当作图片接受,测试 test_extension_cannot_override_file_signature 会失败。

它是什么

文件签名验证是什么

文件签名是文件开头的固定字节序列,例如 PNG 的 b"\x89PNG\r\n\x1a\n" 和 JPEG 的 b"\xff\xd8\xff"

validate_image 通过 payload.startswith(PNG)payload.startswith(JPEG) 检查这些魔数,从而可靠地识别格式。

它不是什么

文件签名验证不是什么

它不是基于文件名后缀的猜测。扩展名是用户可控的元数据,不能作为格式判断的依据。

它也不是完整的图片解码或内容验证;它只检查文件头部的魔数,不保证图片数据完整或可渲染。

它与相邻概念的关系

签名、像素上限与 ImageAsset 的关系

签名检查确定媒体类型,像素上限检查防止资源滥用,两者共同保证 ImageAsset 的合法性。

ImageAsset 是不可变数据类,包含 media_typewidthheight,只有通过验证才能创建。

本阶段的边界决定: 当文件签名不匹配任何已知格式时,必须抛出 ValueError;当宽、高非正或乘积超过 max_pixels 时,也必须抛出 ValueError

STAGE 06

把 JSON、CSV 和 Markdown 解析成带来源定位的块,并对格式错误给出明确失败

先看一个具体问题

为什么 CSV 解析必须保留行号定位信息?

在办公研究场景中,你需要把一份 CSV 表格交给模型处理,但模型无法直接读取原始文件,必须先把 CSV 转换成带来源定位的文本块。

如果解析时只提取文本内容而丢弃行号,后续引用或调试时就无法追溯到原始表格的具体行,证据链会断裂。

当前代码中 forge/documents.py 尚未创建,测试 test_csv_rows_keep_source_locators 会因导入失败而报错,这是本阶段要解决的起点。

先做判断

在实现 parse_document 时,对于 text/csv 类型的输入,以下哪种做法最符合测试要求?

  • 把整个 CSV 文本作为一个块返回,locator 设为 “document”
  • csv.DictReader 逐行解析,为每一行生成 row:i 定位符
  • 把 CSV 当作 JSON 解析,失败时抛出异常
  • 只返回第一行数据,忽略其余行

判断依据: 测试期望返回两个块,locator 分别为 row:1row:2,因此必须逐行解析并生成行号定位符。

它是什么

来源定位是证据链的一部分

来源定位(locator)记录了每个文本块在原始文档中的精确位置,例如 CSV 的行号或 JSON 的根路径 $

它允许后续处理阶段(如检索或生成)引用具体来源,从而支持可验证的引用和调试。

不同文档格式需要不同的定位策略:CSV 按行定位,JSON 按路径定位,Markdown 通常按整个文档定位。

它不是什么

来源定位不是可有可无的元数据

来源定位不是简单的文本标签,它必须能够唯一标识块在原始文档中的位置。

它不是对整个文档的统一标识,例如所有 CSV 行不能都使用 "document" 作为 locator。

它也不是内容的一部分,而是独立于文本内容的元数据,用于追溯和引用。

它与相邻概念的关系

解析器、块与定位符的关系

parse_document 根据媒体类型分派到不同的解析逻辑,每种解析逻辑负责生成相应的块和定位符。

每个 Chunk 对象包含 source(来源文件名)、locator(定位符)和 text(文本内容),三者共同构成可追溯的单元。

测试通过检查 locator 列表来验证解析是否正确,因此定位符的生成规则必须与测试期望一致。

本阶段的边界决定: 当媒体类型为 text/csv 时,必须使用 csv.DictReader 逐行解析,并为每一行生成 row:i 定位符;当媒体类型未知时,必须抛出 ValueError 异常。

STAGE 07

根据模型能力和媒体类型路由输入,并对不支持组合返回可解释拒绝

先看一个具体问题

为什么纯文本模型必须拒绝图片输入?

在上一阶段,你已经实现了文档解析器,能够从图片中提取文本信息。现在需要为系统增加一个统一的输入入口 route_input,它根据媒体类型和模型能力决定是否接受输入。

当前测试 test_text_only_model_rejects_image_with_reason 要求:当模型只支持文本模态时,传入 image/png 必须返回 unsupported 状态,并且 reason 中包含 image

但一个常见的错误是只根据媒体类型路由,认为解析器能处理图片就代表模型也能接受图片。这会导致纯文本模型收到图片输入,产生静默错误。

你需要实现 forge/intake.py,定义 IntakeDecision 数据类和 route_input 函数,同时检查解析器能力和模型模态,对不支持的组合返回可解释的拒绝。

先做判断

在实现 route_input 时,如果只检查媒体类型而忽略 model_modalities,会发生什么?

  • 图片会被路由到 image_adapter,即使模型只支持文本,导致测试失败。
  • 图片会被正确拒绝,因为解析器不支持图片。
  • 文本输入也会被拒绝,因为模型模态检查会误判。
  • 系统会崩溃,因为无法确定适配器。

判断依据: 正确答案是第一个选项。只检查媒体类型会错误地将图片路由到 image_adapter,而测试期望纯文本模型拒绝图片。必须同时检查模型能力。

它是什么

输入路由是双重检查

输入路由是一个决策函数,它接收媒体类型和模型支持的模态集合,返回一个 IntakeDecision 对象,包含状态、适配器和拒绝原因。

路由必须同时验证两件事:解析器是否能处理该媒体类型,以及模型是否具备该模态的输入能力。只有两者都满足时才返回 ready

它不是什么

不是简单的媒体类型映射

输入路由不是仅仅根据媒体类型字符串映射到适配器名称。如果忽略模型能力,就会把不支持的输入交给模型,造成静默错误。

它也不是解析器能力的唯一检查。即使解析器能提取图片中的文本,纯文本模型也无法直接处理图片的原始像素数据。

它与相邻概念的关系

解析器、模型与适配器的关系

解析器负责将原始输入转换为模型可理解的格式,例如从图片中提取文本。模型能力定义了模型可以接收的模态类型,例如 textimage

适配器是连接输入和模型的具体实现,例如 text_adapter 处理文本输入。路由函数根据媒体类型确定模态,再检查该模态是否在模型能力集合中,最后选择对应的适配器。

本阶段的边界决定: 当媒体类型无法识别或模型不支持该模态时,必须返回 unsupported 状态并附带明确的 reason,而不是返回 ready 或抛出异常。

完成本章

让三个场景的文本、图片和结构化文件进入同一输入合同。

FORGE / LOCAL CHECK

本地实验自检

  1. 未开始
  2. 2阅读中
  3. 3实验已下载
  4. 4测试结果已读取
  5. 5本地自检通过

verification.json 只在当前浏览器中解析,不会上传。这里验证的是实验合同,不是服务器认证或第三方背书。

概念校准与一周复习

三道题检查你是否掌握了本章边界、交付证据和恢复方法。答案只保存在当前浏览器。

  1. 1哪一项最能证明你真正完成了「模型运行原理与多模态输入」?
  2. 2关于「上下文」,哪一种理解最准确?
  3. 3实验卡住时,哪个动作是本章建议的最小恢复点?