
在实际的 Agent 开发中真正难住人的往往不是调用一个 LLM API而是当你需要 Agent 完成查询数据、执行计算、读写文件、调用内部系统等一系列操作时工具链该怎么组织。很多 Agent 教程停留在演示一个类似天气查询的 Function Calling 示例一旦进入真实业务就会遇到工具定义混乱、模型不调用工具、上下文越聊越长、错误无法定位等问题。这篇文章不依赖现成 Agent 框架用 Python 从零搭建一套最小但可扩展的智能体工具链把运行循环、工具注册、记忆管理和可观测性这条主线走通。学完之后你可以把这套结构迁移到自己的业务场景中也可以作为理解 LangChain、Semantic Kernel 等框架内部机制的基础。1. 先理解 Agent 工具链解决什么问题1.1 LLM 本身不会调用工具它只会输出文本把一段文字发给大模型模型返回一段文字这是对话。Agent 和普通对话的关键区别在于模型要能够根据任务目标连续地决定下一步做什么并且真正改变系统状态。决定下一步做什么的机制在工程上就是工具链。模型本身不具备调用函数、写文件、查数据库的能力。模型能做的只是输出一段符合协议格式的文本比如一段 JSON里面写着我想调用 calculate 这个工具参数是 {expression: 3*72}。真正去执行这个调用、拿到结果、再把结果交回给模型的是 Agent 运行框架。从模型想调用工具到工具有序被调用并回填结果中间缺的不是模型能力而是一套工程设施这也是 Agent 工具链存在的意义。1.2 一条工具链要覆盖的核心链路一套完整的最小工具链至少包含六个环节。任务接收把用户输入转换成统一的消息结构。规划决策把当前消息和工具列表交给模型让模型决定是输出最终答案还是调用工具。工具执行根据模型返回的 tool_calls找到对应工具解析参数并执行。结果回填把工具执行结果作为 tool 角色的消息放回上下文让模型基于真实结果继续决策。记忆管理维护多轮对话的状态控制上下文长度。终止与异常处理判断任务何时结束超时、报错时如何降级。如果没有这套链路只靠手动拼接 messages 调用模型一旦工具数量变多、调用轮数变深代码立刻会变成一堆互相纠缠的 if else。下面这张表可以更直观地看到直接调用 LLM 和自建工具链的差别。维度直接调用 LLM API自建最小工具链工具能力需要手工拼接提示词模型经常理解错统一注册、统一 Schema、统一执行多轮上下文需要自己维护 messages有记忆管理模块统一裁剪错误处理异常直接抛出断在中间工具错误回填给模型让它自己修正可观测性只能看最终返回每一步请求、工具、耗时都有日志扩展性每加一个工具都要改主流程注册一个新工具即可这条主链路也是后面每一章的组织顺序。先搭地基再实现循环再接入工具最后补记忆和排错。2. 环境准备与项目结构先把地基打对2.1 Python 版本与依赖推荐使用 Python 3.10 或以上版本。本示例只依赖少量第三方库核心代码保持在几百行以内。安装命令如下。python -m venv .venv source .venv/bin/activate # Windows 下使用 .venv\Scripts\activate pip install openai pydantic python-dotenv loguru各依赖的用途如下表。依赖用途说明openai调用兼容 OpenAI 协议的 LLM 接口本地推理服务或国内大模型服务只要提供兼容接口都可用pydantic定义消息结构和工具参数 Schema支持自动校验和 JSON Schema 生成python-dotenv加载 .env 配置文件避免把密钥写死在代码里loguru结构化输出运行日志便于排查调用链和工具执行情况需要说明的是原始需求里没有锁定具体模型和厂商。实际落地时只要目标服务暴露的是 OpenAI 兼容的/chat/completions接口下面的代码都只需要修改base_url和模型名不需要改架构。建议先在本地或国内可用的推理服务上跑通再切换生产模型。2.2 项目目录结构建议按照职责拆分模块而不是把 Agent 逻辑都塞进一个文件。agent_toolchain/ ├── .env # 密钥与环境变量不提交到仓库 ├── config.py # 配置加载 ├── models.py # Message 等核心数据结构 ├── llm.py # LLM 客户端封装 ├── memory.py # 短期记忆裁剪、状态持久化 ├── tools/ │ ├── __init__.py │ ├── registry.py # 工具注册中心 │ └── standard_tools.py # 具体工具实现 ├── agent.py # Agent 运行循环 └── main.py # 入口脚本这里刻意把agent.py、llm.py、tools/registry.py分开。原因是三层职责完全不同llm.py只负责和模型通信tools/registry.py只负责工具管理和调用agent.py负责编排。后面加日志、加记忆、加权限控制时改动可以被限制在单一模块内。2.3 配置加载密钥与运行参数分开创建.env文件LLM_BASE_URLhttps://your-endpoint.example.com/v1 LLM_API_KEYsk-please-replace LLM_MODELqwen-plus LLM_TEMPERATURE0.2 LLM_MAX_TOKENS4096 TOOL_TIMEOUT15 MAX_ITERATIONS10 MEMORY_MAX_MESSAGES24对应config.pyimport os from dataclasses import dataclass from dotenv import load_dotenv load_dotenv() dataclass class Config: llm_base_url: str os.getenv(LLM_BASE_URL, ) llm_api_key: str os.getenv(LLM_API_KEY, ) llm_model: str os.getenv(LLM_MODEL, qwen-plus) llm_temperature: float float(os.getenv(LLM_TEMPERATURE, 0.2)) llm_max_tokens: int int(os.getenv(LLM_MAX_TOKENS, 4096)) tool_timeout: int int(os.getenv(TOOL_TIMEOUT, 15)) max_iterations: int int(os.getenv(MAX_ITERATIONS, 10)) memory_max_messages: int int(os.getenv(MEMORY_MAX_MESSAGES, 24))关键点是密钥和运行参数分开。密钥放.env并由 gitignore 排除模型名、温度、超时时间等作为配置项不要硬编码在业务代码里。temperature建议在 0 到 0.3 之间因为工具调用需要确定性过高的随机度会导致同样的输入时而调用工具、时而不调用。3. 核心数据结构与运行循环Agent 的主动脉3.1 消息结构是整个流程的公共语言Agent 运行过程中所有模块交流的基础是消息。消息结构要和主流模型接口对齐通常包含四种角色。system系统指令设定 Agent 的身份和行为边界。user用户输入。assistant模型回复可能包含文本也可能包含工具调用请求。tool工具执行结果通过tool_call_id与对应调用关联。定义如下from typing import Any, Literal, Optional from pydantic import BaseModel class Message(BaseModel): role: Literal[system, user, assistant, tool] content: str tool_calls: Optional[list[dict]] None tool_call_id: Optional[str] None def to_dict(self) - dict: data {role: self.role, content: self.content} if self.tool_calls: data[tool_calls] self.tool_calls if self.tool_call_id: data[tool_call_id] self.tool_call_id return data这里有两个容易忽略的点。第一assistant消息即使没有文本内容也必须作为历史消息传回模型否则模型会丢失自己刚才调用了哪个工具的上下文。第二tool_call_id必须保留它是模型把工具结果和调用请求对应起来的唯一标识。丢了这个字段后续请求很可能报错或者上下文错乱。3.2 工具注册中心加工具时不改动主流程工具注册中心解决的是模型知道有哪些工具、代码如何找到工具的问题。先定义工具的数据结构from dataclasses import dataclass from typing import Any, Callable dataclass class Tool: name: str description: str func: Callable parameters: dict timeout: int 10再用一个注册器来管理所有工具class ToolRegistry: def __init__(self): self._tools: dict[str, Tool] {} def register(self, name: str, description: str, parameters: dict, timeout: int 10): def decorator(func): self._tools[name] Tool( namename, descriptiondescription, funcfunc, parametersparameters, timeouttimeout, ) return func return decorator def all_schemas(self) - list[dict]: return [ { type: function, function: { name: tool.name, description: tool.description, parameters: tool.parameters, }, } for tool in self._tools.values() ] def get(self, name: str) - Tool | None: return self._tools.get(name)注册器让加一个工具变成写一个函数加一个装饰器主流程完全不用改。这也是工具链扩展性的第一道保障。3.3 Agent 运行循环先执行再观察直到任务结束核心循环是一个有边界的 while 结构。封装 LLM 客户端之后Agent 运行逻辑如下。class Agent: def __init__(self, config, llm, registry, memory): self.config config self.llm llm self.registry registry self.memory memory def run(self, user_input: str) - str: self.memory.add(Message(roleuser, contentuser_input)) for step in range(self.config.max_iterations): message self.llm.chat( self.memory.to_dicts(), self.registry.all_schemas(), ) self.memory.add(message) if not message.tool_calls: return message.content for call in message.tool_calls: result_content self.registry.execute(call) self.memory.add( Message( roletool, contentresult_content, tool_call_idcall[id], ) ) raise RuntimeError(f超过最大迭代次数 {self.config.max_iterations})循环的每一步都在执行同一套逻辑把当前所有消息和工具列表发给模型。模型返回两条路径之一直接给出最终答案或者请求调用工具。如果返回最终答案循环结束。如果返回工具调用逐个执行工具把结果追加为tool消息。带着新消息进入下一轮直到模型给出最终答案或达到迭代上限。max_iterations必须显式设置。没有这个上限一旦模型陷入调用工具、报错、再调用的循环Agent 会在一次任务里消耗大量 token。常见项目可以设为 5 到 15具体取决于任务复杂度。注意不要只验证 Agent 能跑起来还要验证它在模型连续调用 3 次工具时消息顺序依然正确。工具结果必须紧跟对应调用之后顺序错乱会直接影响模型判断。4. 让模型真正用上工具Function Calling 接入细节4.1 工具函数定义描述比实现更重要在 Agent 场景里工具的描述文字直接影响模型是否调用它。假设要实现一个数学计算工具和文件写入工具可以这样写。import ast import operator as op from tools.registry import ToolRegistry registry ToolRegistry() _OPERATORS { ast.Add: op.add, ast.Sub: op.sub, ast.Mult: op.mul, ast.Div: op.truediv, ast.Pow: op.pow, } def safe_eval_math(expr: str) - float: 只允许白名单运算符的数学表达式求值避免直接使用 eval。 tree ast.parse(expr, modeeval) def walk(node): if isinstance(node, ast.Expression): return walk(node.body) if isinstance(node, ast.Constant) and isinstance(node.value, (int, float)): return node.value if isinstance(node, ast.BinOp) and type(node.op) in _OPERATORS: left walk(node.left) right walk(node.right) return _OPERATORS[type(node.op)](left, right) raise ValueError(f不支持的表达式节点: {type(node).__name__}) return walk(tree) registry.register( namecalculate, description计算数学表达式适合价格、数量、统计等数值计算例如 3*72, parameters{ type: object, properties: { expression: { type: string, description: 合法的数学表达式, } }, required: [expression], }, ) def calculate(expression: str) - str: try: value safe_eval_math(expression) return str(value) except Exception as exc: return f表达式无法计算: {exc} registry.register( namewrite_file, description把文本内容写入指定路径的文件适合保存结果或生成报告, parameters{ type: object, properties: { file_path: {type: string, description: 文件完整路径}, content: {type: string, description: 要写入的文本内容}, }, required: [file_path, content], }, ) def write_file(file_path: str, content: str) - str: with open(file_path, w, encodingutf-8) as fp: fp.write(content) return f文件已写入: {file_path}这里做了一个安全设计没有直接使用eval而是用ast解析后只允许白名单运算符。原因很简单工具参数最终来自模型输出模型的输出本质上是不可信的输入任何直接把模型输出拼进执行器的做法都是风险点。工具描述要多写适用场景而不是只写函数功能。例如calculate的描述里加了适合价格、数量、统计等数值计算模型遇到这类任务时才会更容易想到调用它。4.2 参数 Schema用类型定义代替手写 JSON手写 JSON Schema 在工具少的时候没问题工具一多就容易出现 required 漏写、类型不一致、description 缺失。一个更工程化的做法是先用 pydantic 定义参数模型再自动生成 Schema。from pydantic import BaseModel, Field class CalculateArgs(BaseModel): expression: str Field(description要计算的数学表达式例如 3*72) class WriteFileArgs(BaseModel): file_path: str Field(description要写入的文件完整路径) content: str Field(description要写入的文本内容) CalculateArgs.model_json_schema() WriteFileArgs.model_json_schema()model_json_schema()生成的内容就是标准 JSON Schema可以直接放进工具的parameters字段。这样做有三个好处参数类型和校验规则集中在类型定义里不会散落成字符串生成的描述更规范后续还能用同一个模型做服务端参数校验。4.3 工具执行与错误回填错误也是有效观察工具执行不是简单的tool.func(**args)。真实环境中还要考虑超时、参数解析失败、工具内部异常三类问题。ToolRegistry.execute要保证无论发生什么都返回一段可读文本而不是向 Agent 循环抛异常。import json from concurrent.futures import ThreadPoolExecutor, TimeoutError as FuturesTimeout class ToolRegistry: # ... 省略 register、all_schemas、get def execute(self, call: dict, timeout: int | None None) - str: try: name call[function][name] args json.loads(call[function][arguments]) except Exception as exc: return json.dumps( {error: 工具调用参数解析失败, detail: str(exc)}, ensure_asciiFalse, ) tool self._tools.get(name) if tool is None: return json.dumps({error: f未知工具: {name}}, ensure_asciiFalse) try: with ThreadPoolExecutor(max_workers1) as pool: future pool.submit(tool.func, **args) result future.result(timeouttimeout or tool.timeout) return json.dumps({result: result}, ensure_asciiFalse) except FuturesTimeout: return json.dumps( {error: f工具 {name} 执行超时{tool.timeout}s}, ensure_asciiFalse, ) except Exception as exc: return json.dumps( {error: f工具 {name} 执行失败: {exc}}, ensure_asciiFalse, )为什么要把错误封装成字符串回填给模型而不是直接抛出因为对 Agent 来说工具报错本身就是一条重要的观察结果。模型读到执行超时之后下一次决策可能会换成更小的任务、换一个工具、或者直接向用户说明失败原因。如果直接抛异常整个循环就断在这里模型没有机会修正。注意工具返回值和错误信息都要 JSON 序列化后回填。模型接口要求 tool 消息内容为字符串直接把 Python 对象塞进去会导致请求失败。5. 记忆管理多轮对话与长任务的边界5.1 短期记忆滑动窗口裁剪模型上下文窗口是有限的。多轮对话越深历史消息越长最终会碰到两个问题超出模型最大 token 限制或者即使没超推理速度也明显下降。最小可用的短期记忆策略是滑动窗口裁剪。class Memory: def __init__(self, max_messages: int 24): self.max_messages max_messages self.messages: list[Message] [] def add(self, message: Message): self.messages.append(message) self._trim() def _trim(self): system_messages [msg for msg in self.messages if msg.role system] other_messages [msg for msg in self.messages if msg.role ! system] if len(other_messages) self.max_messages: other_messages other_messages[-self.max_messages:] self.messages system_messages other_messages def to_dicts(self) - list[dict]: return [msg.to_dict() for msg in self.messages]裁剪逻辑是保留所有 system 消息然后砍掉最旧的 user、assistant、tool 消息。窗口大小的选择取决于模型上下文长度和业务需要。常见项目从 16 到 32 条开始调试再根据实际效果调整。滑动窗口的问题也很明显过早的用户需求会被遗忘。比如用户第一轮说我是财务组的需要按月统计结果十轮之后 Agent 把这条信息裁剪掉了。要解决这个问题需要长期记忆。5.2 长期记忆把关键信息外部化长期记忆的核心思路是不把所有历史都放进上下文而是把重要的结论、事实、用户偏好抽取出来放到外部存储需要时再召回。学习环境里可以用最轻量的方案每轮对话结束后调用一次模型对旧消息做摘要然后把摘要存入 SQLite。class SummaryMemory: def summarize(self, llm, messages: list[Message]) - str: transcript \n.join( f{msg.role}: {msg.content} for msg in messages if msg.content ) prompt ( 请用200字以内总结以下对话中的关键事实、决策和用户偏好 供后续会话参考不要输出与任务无关的内容\n transcript ) reply llm.chat([{role: user, content: prompt}]) return reply.content生产环境下长期记忆通常会配上向量数据库用 embedding 做语义召回。但原理是相通的先抽取再存储最后按需召回。不要在每次请求时把全部历史都塞回上下文那是用短期记忆的代价解决长期记忆的问题。5.3 状态持久化每次会话都要有存档Agent 执行过程应该可以回溯。最轻量的做法是把每条消息写入 SQLite。import sqlite3 from datetime import datetime class TranscriptStore: def __init__(self, db_path: str agent_transcripts.db): self.conn sqlite3.connect(db_path) self.conn.execute( CREATE TABLE IF NOT EXISTS transcripts ( id INTEGER PRIMARY KEY AUTOINCREMENT, session_id TEXT, role TEXT, payload TEXT, created_at TEXT ) ) self.conn.commit() def save_message(self, session_id: str, message: Message): self.conn.execute( INSERT INTO transcripts (session_id, role, payload, created_at) VALUES (?, ?, ?, ?), (session_id, message.role, message.model_dump_json(), datetime.now().isoformat()), ) self.conn.commit()注意这里保存的是message.model_dump_json()而不是只存 content 字段。因为 assistant 消息的tool_calls和 tool 消息的tool_call_id对排查问题非常重要只存纯文本会丢掉调用链信息。6. 运行验证用一个小任务打通全流程6.1 组装入口脚本完成上述模块后写一个最小入口。from config import Config from llm import LLMClient from memory import Memory from agent import Agent from tools.registry import ToolRegistry from tools.standard_tools import registry def main(): config Config() llm LLMClient(config) registry registry # 已经注册的 calculate、write_file 等工具 memory Memory(config.memory_max_messages) agent Agent(configconfig, llmllm, registryregistry, memorymemory) user_input input(请输入任务) result agent.run(user_input) print(Agent 最终回复, result) if __name__ __main__: main()LLMClient的封装很简单核心是把 OpenAI 兼容接口的请求和响应转换成内部Message。from openai import OpenAI from models import Message class LLMClient: def __init__(self, config: Config): self.client OpenAI(base_urlconfig.llm_base_url, api_keyconfig.llm_api_key) self.model config.llm_model self.temperature config.llm_temperature self.max_tokens config.llm_max_tokens def chat(self, messages: list[dict], tools: list[dict]) - Message: kwargs { model: self.model, messages: messages, temperature: self.temperature, max_tokens: self.max_tokens, } if tools: kwargs[tools] tools kwargs[tool_choice] auto response self.client.chat.completions.create(**kwargs) choice response.choices[0].message tool_calls None if choice.tool_calls: tool_calls [call.model_dump() for call in choice.tool_calls] return Message( rolechoice.role, contentchoice.content or , tool_callstool_calls, )6.2 执行任务并验证结果运行入口后输入任务python main.py示例输入计算 15 个单价为 12.5 的商品总价并把结果写入 result.txt预期流程应该如下。模型返回calculate工具调用参数为{expression: 15*12.5}。Agent 执行计算把结果187.5回填给模型。模型返回write_file工具调用参数为{file_path: result.txt, content: 187.5}。Agent 执行写入把成功信息回填。模型输出最终答案循环结束。验证点不只是程序没有报错而是检查以下几点。result.txt文件确实存在内容为187.5且不是模型凭空生成的错误数值。日志里能看到两次工具调用的顺序和耗时。Agent 的最终回复包含了计算结果和文件路径而不是只复述任务。6.3 异常分支验证再验证一个异常场景故意输入一个数学计算会失败的任务或者让工具超时。计算 (3 的平方并保存结果此时模型可能调用calculate但传入非法表达式工具返回表达式无法计算这个错误会被回填。好的 Agent 会根据自己的错误观察重新生成表达式而不是直接放弃。验证时要关注模型是否根据 tool 消息修正了行为。注意Agent 测试要覆盖调用成功“调用失败”“工具结果导致模型改变计划”三条路径。只测 happy path上线后大概率会在第一轮真实任务里翻车。7. 常见问题排查按这条链路查7.1 典型问题与处理方向问题现象常见原因排查方式处理建议模型一直不调用工具工具描述不清晰、temperature 过高、Schema 过于复杂打印实际发送的 tools 列表和模型原始响应降低 temperature简化参数描述里写适用场景工具参数频繁格式错误Schema 缺少 required、模型对参数含义理解偏差查看 tool_call 原始 arguments对比 Schema用 pydantic 生成 Schema加上更详细参数描述工具执行超时工具内部有网络或 IO 操作没设超时查看 loguru 日志中的耗时给每个工具配置 timeout重活异步化上下文超出模型限制工具结果太大、历史消息太多统计 messages 总 token 数滑动窗口裁剪、截断工具返回、摘要化任务中途退化成死循环缺少迭代上限、错误不断回填但模型无法修正查看循环步数和每轮 tool_calls设置 max_iterations限制单轮工具调用数量7.2 模型不调用工具先查输入再查配置排查顺序是先确认 tools 参数真的传进了请求再确认模型返回的 content 里有没有把工具调用描述成文本最后检查 temperature。很多情况下模型不是没有能力调用工具而是工具描述写得太抽象。比如写文件这个描述就不如把文本内容写入指定路径适合保存计算结果和生成报告清晰。另一个常见原因是tool_choice没有设为auto。不传这个参数时不同服务的行为不一致有的会默认不调用工具。7.3 工具参数解析失败问题多在 Schema如果日志里频繁出现工具调用参数解析失败先打印原始 arguments 字符串。模型返回的{file_path: a.txt, content: hello}和普通字符串不一样有时候夹带换行、多余引号甚至参数名是拼音或者翻译偏差。解决方案不是去猜而是让服务端做严格校验。用 pydantic 定义参数模型在execute里先model_validate(args)校验失败时把错误信息回填给模型让模型自己纠正参数。这个方法比在提示词里反复强调参数必须正确可靠得多。7.4 工具结果太大上下文被撑爆真实业务里最常见的上下文膨胀来源不是历史对话而是工具返回的大段数据。一个查询库存的接口可能返回几千行 JSON全部塞回 messages 后下一轮请求直接超过模型限制。建议在工具层做三件事限制返回长度只返回结构化摘要对过大结果按条截断。例如查询类工具可以增加limit参数默认只返回前 20 条记录并在结果里附带总记录数让模型知道还有更多数据。8. 生产环境加固与可落地清单8.1 安全边界工具不是越多越好Agent 工具链的权限模型要认真设计。工具执行的是真实系统操作写文件、执行命令、调用内部接口都可能带来风险。生产环境至少要考虑以下几点。工具白名单只有注册过的工具可以被调用拒绝未知工具名。参数校验模型输出的参数先走 pydantic 校验再进入业务逻辑。敏感操作拦截涉及删除、覆盖、发送消息、转账等操作需要二次确认或权限标记。工具执行沙箱必要时在受限容器或子进程中执行限制文件系统访问和网络访问。超时与并发控制给每个工具设置独立超时控制最大并发工具数。不要相信模型输出的任何参数。模型输出是自然语言概率分布的产物它不是可信的代码输入。所有工具参数都要当作外部数据对待。8.2 可观测性每次决策都要有迹可循Agent 排查困难的根本原因是链路太长一次任务可能包含多轮模型调用和多轮工具调用。没有可观测性时出了问题只能猜。生产环境建议输出结构化日志至少包含以下字段。字段含义示例session_id会话标识sess_001step当前迭代轮次3event事件类型llm_request / tool_exec / final_answertool_name工具名calculatelatency_ms耗时245error错误信息timeout同时保存完整 transcript包括每条 assistant 的tool_calls和每条 tool 消息的tool_call_id。这是事后追溯模型决策路径的唯一依据。8.3 发布前检查清单把下面这份清单作为 Agent 功能上线的最后一道检查。环境检查模型地址、密钥、模型名是否都来自配置代码里没有硬编码。工具检查每个工具是否都有清晰描述、完整参数 Schema、超时设置。安全检查敏感工具是否有权限校验和操作确认。异常检查工具报错是否会回填给模型而不是直接中断。边界检查是否设置了max_iterations工具结果是否限制最大长度。可观测性检查每次模型请求和工具调用是否都有日志和耗时。回归检查是否跑过至少一个多次调用工具的任务和至少一个工具报错后模型修正的任务。成本检查单次任务平均调用模型次数和 token 消耗是否在预算内。学习环境可以跳过权限、沙箱、监控等生产项但不能跳过边界检查和异常检查。Agent 和普通接口的最大差别在于它的行为是概率性的不是固定的。没有边界控制的 Agent即使测试通过也可能在真实输入下做出不可预测的事情。这套工具链目前是最小但完整的状态。下一步扩展时建议按这个顺序走先加工具权限和参数校验再加会话持久化然后加长期记忆召回最后再考虑多 Agent 协作或复杂规划器。越往后对前几步的地基质量要求越高。最终的工程判断是Agent 不只是一个模型调用循环它是一套需要像维护业务系统一样认真维护的工具链。把运行循环、工具注册、记忆、可观测性四条主线处理好比追着换新模型、新框架更能提升系统的稳定性。对新手来说最值得做的练习不是跑通官方 Demo而是把这一套代码亲手写一遍然后故意制造工具报错、上下文溢出、模型不调用工具三种故障再用日志把原因一个个找出来。