利用LangGraph和Ollama构建一个具备自我修正功能的文档生成代理。
一种本地反射式处理流程,其中一个模型负责起草Python API文档,第二个模型对其进行评审,第三个模型则整合优化结果,这些环节通过LangGraph在Ollama上以循环方式连接。
来源: Build a Self-Correcting AI Agent with LangGraph and Ollama
这种模式被称为“反射机制”。其基本理念是,大型语言模型在识别文本中的缺陷方面远比一次性写出完美文本的能力更强,因此工作被分解为不同的角色。有一个关键点:每个角色使用不同的本地模型。既负责生成内容又负责评分的模型往往会放任自己的输出通过,而独立的评估模型则能避免这种情况。
早些时候,那些在没有框架的情况下从零构建的智能体认为,一旦手动构建出简化版本,像LangGraph这样的工具就会发挥作用。而这次选择LangGraph是经过深思熟虑的,因为带有条件退出的迭代循环正是它所设计的循环工作流程。Ollama负责提供模型。默认设置中,生成器为qwen3.5:9b,评估器为deepseek-coder:6.7b,裁决者为llama3:8b,不过通过一个小的JSON文件即可指定各角色的职责,因此任何本地下载的模型都可以替代它们。
最终得到的只是一个控制台命令 reflexion。它会读取源文件,运行循环,为每一轮输出评分表,并将处理后的 Markdown 内容写入磁盘。整个流程仅包含在一个约 360 行的 Python 文件中。
该系统由七个部分组成:工作流状态与评价规则架构、生成器节点、评估节点、负责决定是继续循环还是终止的路由节点、裁决节点、图形连接结构,以及处理配置和输出清理的运行时模块。
设置
相关代码存放在名为 local-reflexion-agent 的仓库中,以一个包的形式存在,即 src/reflexion/main.py,通过 pyproject.toml 作为控制台脚本暴露出来。请克隆该仓库并以可编辑模式安装:
git clone https://github.com/jfjensen/local-reflexion-agent.git
cd local-reflexion-agent
pip install -e .
这会引入 langgraph、langchain-core、langchain-community、langchain-ollama 以及 pydantic,并注册一个名为 reflexion 的控制台脚本。接着确认 Ollama 已在本地运行,然后下载 config.json 中列出的三个模型:
ollama pull qwen3.5:9b
ollama pull deepseek-coder:6.7b
ollama pull llama3:8b
不同的模型都可以。角色分配在仓库根目录下的 config.json 文件中,具体内容会在下文的运行时部分再次说明。该仓库在 input/ 目录下提供了三个小型示例文件,因此可以直接运行该流程:
reflexion --input input/source_function_a.py --output output/result.md --config config.json
第一部分:状态与评估器的结构
首先需要两种数据结构。一种用于定义评估器必须返回的内容,另一种则用于定义在图中传递的数据。
评估器的输出才是更重要的部分。如果向本地6.7B模型请求评分,它会愉快地用一段文字回答。但实际上需要的是数字以及一个可操作的修改建议,因此Pydantic结构体能强制模型填写固定的字段:
class DocEvaluationSchema(BaseModel):
"""Pydantic schema used to force a structured JSON schema constraint out of our local critic model.
Deliberately contains only judgment fields: the overall average and the pass/fail decision are computed in code."""
technical_accuracy: int = Field(description="Score from 0-100 on accuracy.")
completeness: int = Field(description="Score from 0-100 covering parameter definitions.")
single_critical_fix: str = Field(description="The single absolute highest priority correction required.")
关于该架构的几点说明:
single_critical_fix字段是该循环的核心。批评者无需列出所有问题,只需指出最需要优先解决的那个问题即可。相比面对一大堆混杂的反馈,简单的生成器在处理“只修复这个问题”时表现更好。- 遗漏的内容与包含的内容同样重要。早期版本还要求模型给出整体百分比以及通过/失败标志,但这被证明是个错误,相关说明见“批评者”部分。简而言之:绝不要让模型去做Python只需一行代码就能完成的算术运算或阈值比较。该架构仅保留模型真正需要的判断信息,而平均计算及通过/失败判定则由批评者节点用普通代码来完成。
Field描述视为装饰性内容。它们会作为JSON模式约束的一部分传递到模型中,起到类似简短提示的作用。接下来是状态管理。LangGraph会将一个状态对象从节点传递到另一个节点,每个节点则通过对该对象的局部更新来响应。TypedDict用于定义其结构:
def append_scores(old: List[int], new: List[int]) -> List[int]:
return old + new
class AgentWorkflowState(TypedDict):
"""Memory tracking state for the heterogeneous local graph layout."""
source_code: str
generated_markdown: str
current_score: int
score_history: Annotated[List[int], append_scores]
required_fix: Optional[str]
iteration_count: int
is_approved: bool
final_adjudicated_markdown: Optional[str]
唯一不太显而易见的行是 score_history。其类型 Annotated[List[int], append_scores] 告诉 LangGraph 应将该字段的更新与 append_scores 约简器合并,而非直接覆盖。因此,评估器返回的每个分数都会被追加到历史记录中。在运行结束后,就可以得到所有修订轮次中的完整分数曲线,这被证明是该流程输出的最有信息量的内容。
第二部分:生成器节点
LangGraph 节点本质上就是一个函数:输入为状态,输出为更新后的字典。生成器节点有两种模式。第一轮直接从代码中生成文档初稿;后续各轮则根据评估器的修改意见对之前的初稿进行修订。
def content_generator_node(state: AgentWorkflowState) -> dict:
"""The Generator node: Focuses entirely on drafting/editing documentation text layout."""
current_iteration = state.get("iteration_count", 0)
target_model = MODEL_REGISTRY["GENERATOR"]
# ... console logging trimmed, see the repo for the full version
llm = ChatOllama(model=target_model, temperature=0.7)
if not state.get("generated_markdown"):
system_prompt = "You are an expert technical writer. Convert raw code snippets into clean, comprehensive API markdown documentation."
user_prompt = f"Generate Markdown documentation for the following source code:\n\n{state['source_code']}"
else:
system_prompt = "You are a precise editor rewriting technical documentation to fix a structural flaw."
user_prompt = (
f"Your previous documentation draft was scored {state['current_score']}/100.\n"
f"You must completely rewrite the documentation to fix this specific issue: {state['required_fix']}\n\n"
f"Original Code Context:\n{state['source_code']}\n\n"
f"Previous Draft:\n{state['generated_markdown']}"
)
messages = [SystemMessage(content=system_prompt), HumanMessage(content=user_prompt)]
response = llm.invoke(messages)
return {
"generated_markdown": response.content,
"iteration_count": current_iteration + 1
}
代码的功能:
模式选择。如果generated_markdown为空,表示处于第一轮,此时模型会以技术文档撰写者的身份从原始内容开始创作。如果已有初稿存在,模型则会被设定为编辑角色,并获得之前的评分、审稿人的修改建议、原始内容以及自己之前的初稿。
修订提示。告知模型其先前的评分是一个有效的技巧。明确指出“你的得分是78/100”会促使模型对内容进行实质性修改,而非仅仅稍作改写。指令要求针对某个具体问题彻底重写内容,这样的明确要求很适合小型本地模型。
温度参数。生成器以temperature=0.7的参数运行,这样能在各轮之间为初稿带来一定的多样性。而如后文所示,审稿器则使用零温度参数运行。
返回值。仅会返回发生变化的字段。LangGraph会将这些字段合并到状态中,且每完成一轮迭代,iteration_count的值就会增加1。
第三部分:Critic节点
这是个不同的模型,默认为deepseek-coder:6.7b,其提示语的优化所需尝试次数比该系统中的其他模型都要多。
def content_critic_node(state: AgentWorkflowState) -> dict:
"""The Critic node: Cross-checks generator output using a different model with strict JSON formatting."""
target_model = MODEL_REGISTRY.get("CRITIC")
llm = ChatOllama(model=target_model, temperature=0.0)
structured_llm = llm.with_structured_output(DocEvaluationSchema)
system_prompt = (
f"You are a meticulous QA inspector evaluating API documentation against its source code. "
f"You are grading the DOCUMENTATION, not the source code. Never deduct points because the source code "
f"itself could be designed better, and never ask for the source code to be changed. "
f"Score by deduction: start from 100 and subtract points only for concrete errors you can verify by "
f"pointing at specific documentation text that contradicts the source code. "
f"Check that every 'raise' statement in the source is documented with its exact exception type and its "
f"exact quoted error message: a missing, denied, or misquoted exception is a major deduction. "
f"Recompute every numeric value shown in the documentation's examples against what the source code would "
f"actually return: a wrong number is a major deduction. "
f"If you find no verifiable errors, score 100. "
f"The single_critical_fix must point at the documentation text to change, not at the source code."
)
user_prompt = (
f"Evaluate the following markdown documentation relative to its raw source code.\n\n"
f"Raw Source Code:\n{state['source_code']}\n\n"
f"Generated Markdown Document:\n{state['generated_markdown']}"
)
evaluation: DocEvaluationSchema = structured_llm.invoke([
SystemMessage(content=system_prompt),
HumanMessage(content=user_prompt)
])
# The model provides the judgment scores; the arithmetic and the pass/fail decision are done here in code.
overall_score = round((evaluation.technical_accuracy + evaluation.completeness) / 2)
is_approved = overall_score >= QUALITY_SCORE_BAR
required_fix = sanitize_critical_fix(evaluation.single_critical_fix)
return {
"current_score": overall_score,
"score_history": [overall_score],
"required_fix": required_fix,
"is_approved": is_approved
}
结构化输出。 关键在于 llm.with_structured_output(DocEvaluationSchema) 这一行代码。它对模型进行约束,确保返回的是经过验证的 DocEvaluationSchema 对象而非自由文本。这样一来就无需进行 JSON 解析、正则匹配,也无需为格式错误的输出处理重试逻辑。评估时将 temperature 设置为 0.0,因为评分工具应当具有确定性,或者至少本地模型要尽可能接近这种确定性。
提示词的发展过程。 它经历了三个版本,而这一历史记录或许是最有借鉴意义的经验。
- 第一个版本礼貌地要求模型分析文档并给出评分。结果却是,那些包含虚构错误信息的文档也得到了80多分或90多分的“较高”评分,因此循环过早终止了。
不要将算术运算纳入模型中。最初的方案要求模型自行计算出总体百分比以及通过/失败的布尔值,但输出结果总是不正常。有一次,评估系统给技术准确性打了100分、完整性也打了100分,随后却报告“净平均值”恰好为95分,而这实际上只是提示中提到的及格标准,并非任何数值的真实平均值。还有一次,在连续四轮测试中,该系统都给初稿打了97分,而及格标准是95分,却每次都判定为未通过。这四次尝试全都白费了,因为路由器相信那个布尔值,而日志也明确指出97分未达到95分的标准。
目前该模型仅输出两个子分数。其余所有工作均由三行Python代码完成:计算平均值,将其与QUALITY_SCORE_BAR进行比较,进而设置路由器需遵循的标志。将数值与阈值进行比较并非需要判断的行为,任何无需判断的事情都不应交由模型处理。
sanitize_critical_fix保护机制。在同样的四轮测试中,某次评估反馈中出现数百个重复的../标记,这些标记被原封不动地作为需要修复的问题粘贴到生成器的下一轮提示中。在结构化输出约束下运行的小型模型偶尔会出现此类行为。该保护机制会检测修复内容是否为空或过于简单(如仅包含一个重复标记,或仅有少数不同字符的文本),若是如此,则会替换为一个通用请求,要求重新对照源代码检查文档。虽然并不复杂,但能有效防止垃圾内容影响整轮测试的进展。整个函数非常简短:
def sanitize_critical_fix(fix: Optional[str]) -> str:
"""Guards against degenerate critic feedback (empty strings or runaway token repetition)."""
stripped = (fix or "").strip()
words = stripped.split()
is_degenerate = (
not stripped
or len(set(stripped)) <= 4
or (len(words) > 5 and len(set(words)) <= 2)
)
if is_degenerate:
return "Re-verify the documentation line by line against the source code and correct the weakest section."
return stripped
每轮结束后,分数会以包含单个元素的列表形式返回,之前定义的状态还原函数会将该元素追加到历史记录中。如此一来,曲线便逐渐形成。节点还会打印出本轮的评分表,显示技术准确性、完整性、相对于基准线的净分数以及评审者的反馈。这些打印内容虽未出现在摘录中,但保存在代码仓库中,而逐轮查看评分表正是其中的乐趣所在。
第4部分:路由器
生成器和评审器已经完成工作,现在需要有某个组件来决定每次评审后应执行什么操作。在LangGraph中,这项任务由路由函数负责,它读取当前状态并返回下一个节点的名称。
def execution_router(state: AgentWorkflowState) -> Literal["content_generator", "content_adjudicator"]:
"""Monitors workflow states and determines whether to repeat, loop, or step out to adjudication."""
if state["is_approved"]:
return "content_adjudicator"
if state["iteration_count"] >= MAX_ATTEMPTS:
return "content_adjudicator"
return "content_generator"
关于路由器的说明:
- 该循环只有两个出口:要么评审者批准草稿,要么达到尝试次数限制。无论哪种情况,草稿都会被提交给评审节点;在其他所有情况下,则会返回给生成器。
- 这个限制其实需要更重视。以95分为及格线且评审标准严格的情况下,某些草稿永远无法通过;而如果没有
MAX_ATTEMPTS设置,循环将会无限次地调用Ollama。4次尝试是一个人为设定的但可行的数值,也可根据需要进行调整。 Literal返回类型不仅仅是文档说明,因为它与构建图结构时定义的路由映射相匹配。- 与其他节点一样,摘录内容中不会包含控制台输出信息。在代码仓库中,路由器会在每次决策前记录尝试次数和得分历史。
第5部分:评审节点
无论是因为通过审核还是尝试次数用尽而离开循环的内容,都会由第三个模型进行最终审核。该审核模型的任务并非添加内容,而是解决在多轮修改中出现的矛盾,并剔除任何带有对话性质的表述。
def content_adjudicator_node(state: AgentWorkflowState) -> dict:
"""The Adjudicator node: Runs outside the loop to resolve structural contradictions and finalize text."""
target_model = MODEL_REGISTRY["ADJUDICATOR"]
llm = ChatOllama(model=target_model, temperature=0.2)
system_prompt = (
"You are a senior technical documentation editor giving the final word on an API document "
"that has been through multiple revision rounds. Your job is to read the raw source code, "
"look at the latest draft, eliminate any conflicting statements, clean up semantic prose, and output the polished final markdown document. "
"Do not include any preamble, comments, conversational pleasantries, or explanations-return ONLY the markdown documentation."
)
user_prompt = (
f"Review and finalize this documentation template.\n\n"
f"Raw Source Reference Code:\n{state['source_code']}\n\n"
f"Latest Revision Draft:\n{state['generated_markdown']}\n\n"
f"Last Remaining Critic Concern:\n\"{state['required_fix']}\""
)
response = llm.invoke([SystemMessage(content=system_prompt), HumanMessage(content=user_prompt)])
return {"final_adjudicated_markdown": response.content}
该节点存在的理由。经过多轮“彻底重写以解决X问题”后,初稿可能会出现内部矛盾:某一轮添加了某种表述,而后来的重写又与之冲突,却并未删除原有内容。审核员会收到原始文本、最新版本的初稿以及批评者提出的最后未解决的问题,并被要求删除那些相互矛盾的陈述。对于始终无法通过审核的初稿,这一环节也起到了终止处理的作用。这样一来,至少能得到一个经过协调的版本,而无需直接提交未经处理的第四次尝试成果。
温度参数。其运行时的temperature=0.2。这个数值较低是因为编辑才是主要任务,但并非为零,因为系统仍需对文本进行重写。
仅支持 Markdown 格式。本地模型习惯以“当然!这是您的文档:”开头,以“希望这对您有帮助。”结尾。提示词会禁止这两种表达方式。正如运行时部分所示,仅靠提示词是不够的,因此需要通过后处理步骤来去除那些未被过滤的内容。
第6部分:构建图结构
现在开始将各个组件组合起来。正是在这里,LangGraph展现出了它的优势——因为包括循环在内的整个拓扑结构只需几行代码即可表示:
builder = StateGraph(AgentWorkflowState)
builder.add_node("content_generator", content_generator_node)
builder.add_node("content_critic", content_critic_node)
builder.add_node("content_adjudicator", content_adjudicator_node)
builder.set_entry_point("content_generator")
builder.add_edge("content_generator", "content_critic")
builder.add_conditional_edges(
"content_critic",
execution_router,
{
"content_generator": "content_generator",
"content_adjudicator": "content_adjudicator"
}
)
builder.add_edge("content_adjudicator", END)
agent_pipeline = builder.compile()
节点与入口点。三个节点函数均以名称形式注册,其中Generator作为入口点。Generator总会将控制权交给Critic,这一流程是无条件的。
条件分支。函数add_conditional_edges负责将路由器与Critic连接起来。一旦评价过程完成,路由器就会启动,其输出结果会通过映射表查找下一个节点。这种映射机制形成了循环,因为content_critic可以再次指向content_generator,而后者又会为content_critic提供输入。
编译过程。调用builder.compile()后会生成一个可运行的处理流程,因此整个循环最终可通过一次agent_pipeline.invoke(initial_state)调用来完成。
凡是亲手编写过这种使用 while 语句、手动管理状态以及尝试次数的循环的人,都会发现需要处理的繁琐事务大幅减少了。图表声明还能以手动实现的循环所无法做到的方式自我说明。
第7部分:运行时、配置与输出清理
最后是 reflexion 控制台脚本背后的 run() 函数。它负责解析参数、加载配置、执行处理流程并清理输出结果。其中大部分参数解析逻辑可在代码仓库中查看,但有两处值得重点关注。
首先是配置部分。config.json 文件提供了模型角色及循环设置:
{
"model_registry": {
"GENERATOR": "qwen3.5:9b",
"CRITIC": "deepseek-coder:6.7b",
"ADJUDICATOR": "llama3:8b"
},
"quality_score_bar": 95,
"max_attempts": 4
}
如果文件不存在或无法读取,加载器仍会使用这些默认值,从而确保流程能够启动。输入、输出和配置的默认位置是相对于已安装的包来确定的,对于可编辑安装而言,即指仓库的根目录。因此,从任意目录运行纯 reflexion 命令时都会找到仓库中的 config.json 文件及 input/ 文件夹,而 --input、--output 和 --config 参数可分别覆盖这些默认值。
其次是对输出结果的清理,这是处理本地模型时所必需的:
raw_final_text = final_output.get("final_adjudicated_markdown", "")
clean_final_markdown = raw_final_text
# 1. Clean Preamble (Conversational text at the top)
if "#" in raw_final_text:
parts = raw_final_text.split("#", 1)
if len(parts[0].strip()) > 0 and len(parts[0].strip()) < 200:
clean_final_markdown = "#" + parts[1]
# 2. Clean Postscript (Conversational and meta review text at the bottom)
meta_prefixes = (
"note:", "note that", "i hope", "this concludes", "in conclusion",
"no formatting errors", "no contradictions", "no errors",
"let me know", "feel free", "please let me know"
)
meta_keywords = ("errors or contradictions", "contradictions were found", "errors were found")
lines = clean_final_markdown.rstrip().split("\n")
while lines:
last_line = lines[-1].strip()
if not last_line:
lines.pop()
continue
lowered = last_line.lower()
# Real content usually carries Markdown structure; meta chatter does not.
is_structural = lowered.startswith(("#", "|", "-", "*", ">", "`")) or lowered[0].isdigit()
is_meta = (not is_structural) and (
lowered.startswith(meta_prefixes) or any(k in lowered for k in meta_keywords)
)
if not is_meta:
break
lines.pop()
clean_final_markdown = "\n".join(lines).rstrip() + "\n"
其行为方式:
- 前言检查。如果文本出现在第一个
#标题之前且长度小于200个字符,那它很可能是“以下为最终文档”的开头内容,会被截断。如果长度超过200个字符,则会保留,因为这可能是真实内容。 - 后记扫描。代码从最后一行开始逐行检查。如果某行看起来像是模型生成的废话而非实际内容,比如残留的评审意见,或是以“注意:”或“我希望……”开头的结束语,例如“未发现格式错误或矛盾之处”,而这些内容可能是某次测试运行留在原本不错的文档末尾的,那么该行就会被删除。由于是循环检查而非单次检查,多行连续的结束语会在一次扫描中全部被移除。
清理完成后,最终的 Markdown 内容会被保存到指定输出路径。随后控制台会显示运行摘要,包括完整的得分历史以及循环次数。
尝试使用
该仓库附带了三个示例输入:一个用于过滤指标的函数、一个 API 签名检查工具以及一个复利计算器。第一个示例的文档编写方式如下:
reflexion --input input/source_function_a.py --output output/result.md --config config.json
从那里可以看到循环的运行过程。每一轮,生成器会宣布自己的存在,评估器会打印出评分表,而路由器则会说明是继续循环还是终止。
三种输入组合的运行结果都保存在 output/ 文件夹中,因此无需实际运行即可查看流程的输出结果:整齐的参数表、返回值描述,以及包含完整错误信息的异常列表。这三个文件中有两个含有如下所述的小错误,即声明的输出结果与代码实际返回的结果不一致,或者本应返回正常值的场景却引发了异常。这些错误是故意留下的,找到它们可以成为一项不错的练习。
出现故障时
确实会出现问题,下表汇总了测试过程中发现的故障。在某次测试中,文档错误地指定了异常类型并编造了错误信息,而实际代码抛出的却是带有具体字符串的TypeError。另一次测试中,文档称并未明确抛出任何异常。版本历史表中出现了一行虚构的“0.1初始版本”,日期标注为“待定”,但这些内容在原始代码中根本不存在。还有一个示例显示的产出比率接近0.86,而正确答案应为0.667。还有一份还算不错的文档以评审者自己的结论作为结尾:“未发现格式错误或矛盾之处。”
还有那次测试,尽管明确表示草稿尚未完成,却连续四轮给出了97分的高分,而基准分仅为95分;其中一轮的反馈竟变成了数百个../标记。当批评提示被设置得过于严厉时,它连续四轮对一份基本正确的草稿给出了0分的结果,并用明显错误的陈述为这一分数辩护。该批评工具既非固定裁判也非计算器,它会放大提示赋予它的任何情绪倾向。
幸运的是,这些故障会成簇出现,而代码中针对每一簇都设计了相应的缓解措施。内容方面的故障,比如文档中出现错误的异常信息或算术错误,会通过批评提示中的命名验证检查来处理。校准方面的故障(一端是敷衍了事的审批,另一端则是极端的零分评价),则通过基于扣分的评分机制来解决,并将平均值和阈值判断逻辑从模型中移至批评节点的 Python 代码中。输出方面的故障、最终审批结果以及剩余的审核判定,会通过后处理步骤来处理,而异常反馈则由净化保护机制来应对。
尽管采取了这些措施,循环机制依然无法做到万无一失,因为提示指令仅是一种请求而非确凿证据。即便在采取了所有这些手段后分数仍波动不定,最关键的还是模型本身而非提示内容。在评估系统中,评价模块承担着最艰巨的任务;只需将config.json中的CRITIC条目替换为性能更强的通用模型,即可实现无需编写代码的一行式修改。
与向同一生成器发送单次提示相比,该循环机制能够稳定地生成质量高得多的文档,而分数记录则真实反映了达到这一效果的过程。如果运行次数持续触及尝试上限,有两个参数值得调整:quality_score_bar的值可低于95,max_attempts的值则可高于4。
整合应用
最终得到的是一个包含约360行的单文件处理流程,其功能包括:
- 共享状态以及为评估者设计的结构化评估方案,平均分和通过判定均由 Python 计算,而非模型本身完成。
- 先由一个本地模型撰写初稿,再由另一个模型进行评阅,从而避免模型对自己生成的成果打分。
- 存在一个路由机制,会不断返回初稿以供进一步修改,直至达到质量标准或修改次数用尽。
- 会启用第三个模型作为最终裁决者,以解决多轮修改后遗留的矛盾问题。
- 在将最终 Markdown 内容写入磁盘之前,会去除其中的对话式开头和结尾内容。
- 所有设置均保存在小的
config.json文件中,因此更换模型或调整流程无需修改任何代码。
该系统还有扩展空间。批评者可以针对多个评估维度打分,由路由器选择最需改进的方面进行修复,而无需只进行单一的修正。第四个节点可以运行文档中的代码示例,并将实际出现的错误反馈回循环中,从而发现批评者可能遗漏的错误说法。或者,同样的生成器、批评者和评判者结构也可以用于完全不同的任务,比如翻译或测试用例生成,因为该架构中没有任何内容是专门为文档处理设计的。
在这里,框架的优势就显现出来了:只有当工作流具备真正的循环、分支和共享状态时,框架才能发挥其作用,而这个流程正好符合这一特点;LangGraph负责处理那些没人愿意手动实现的环节,即状态合并、条件路由以及循环本身。先构建简化版本可以清楚地看出框架中哪些部分在真正发挥作用,这种顺序值得推荐。