📑 本页目录(点开跳转)
挑战项目 A · 手搓一个迷你 Agent 框架
⏱ 5–7 天 | 难度 ★★★★☆ | 前置:第 1–11 节
🎯 为什么选这个项目(它的「特点」)
市面上的 Agent 教程都教你用 LangChain / Agent SDK。这个项目反过来:从一个裸的 HTTP 请求开始,把框架本身造出来。
你会亲手实现这些"框架黑箱"里的东西:
① 工具注册与调度(装饰器自动生成 schema)
② 上下文压缩(对话满了自动总结重启)——第 4 节
③ 子 Agent 派生(隔离上下文,只回摘要)——第 4、13 节
④ 护栏(max_steps / 超时 / 预算上限)——第 1 节
⑤ 可观测性(结构化 trace,能回放每一步)——第 14 节
做完你会得到一个"啊,原来 LangChain 就这?"的顿悟。
之后用任何框架,你都知道皮下是什么,出问题知道去哪找。
这个项目无可替代的价值:把前 11 节的概念焊死成代码。读十遍「上下文压缩」,不如自己写一次触发压缩的那个 if。
🏗️ 目标架构
MiniAgent
├── @tool 装饰器 ── 自动从函数签名和 docstring 生成工具 schema
├── run() 主循环 ── 第1节的循环,加上:
│ ├── 每轮检查 token 预算,超阈值 → compact()
│ ├── max_steps / 超时护栏
│ └── 结构化 trace 记录每一步
├── compact() ── 历史 → 摘要,保留关键事实
├── spawn_subagent() ── 开一个干净上下文的子 Agent,只回摘要
└── Tracer ── 每步的 (思考/工具调用/结果) 存成可回放的 JSON
唯一的外部依赖:一个 LLM API(或第 1 节的 MockModel 升级版,用于零成本开发)。不许用任何 Agent 框架——这是重点。
🧠 ADHD 任务切分
第一阶段:核心循环 + 工具系统(Day 1–2)
- [ ] T1 (90min)
@tool装饰器:读函数的类型注解和 docstring,自动生成{name, description, parameters}schema。这样注册工具只需@tool一行 - [ ] T2 (90min)
MiniAgent.run():第 1 节循环 + max_steps/超时/预算三道护栏。护栏触发时要优雅退出(返回已有进展),不是崩溃 - [ ] T3 (60min) 用 MockModel 跑通(MockModel 升级:能按工具 schema 生成合法调用)
- [ ] T4 (45min) 接真模型(用官方 tool use API,对比自己解析 JSON 的差别)
第二阶段:上下文管理(Day 3–4)—— 框架的精华
- [ ] T5 (90min)
Tracer:把每一步{step, thought, tool_call, tool_result, tokens}记成 JSON。这是后面 debug 一切的眼睛 - [ ] T6 (120min)
compact():估算当前 token(简单用字符数/4),超过阈值时调模型总结历史。⭐ 关键难点:摘要必须显式保留「已完成/当前状态/下一步/关键事实(路径、决策、报错)」,否则压缩后 Agent 会失忆重错 - [ ] T7 (60min) 压缩测试:造一个需要 20+ 轮的任务,验证压缩后 Agent 没丢关键信息、能继续正确工作
第三阶段:子 Agent(Day 5)
- [ ] T8 (90min)
spawn_subagent(task, tools):开一个全新MiniAgent实例(干净上下文),跑完只把最终摘要返回主 Agent - [ ] T9 (60min) 把「spawn_subagent」本身也做成一个
@tool,让主 Agent 能自己决定「这个调查派给子 Agent」 - [ ] T10 (45min) 验证上下文隔离:子 Agent 读了 8000 字,主 Agent 的上下文只增加了那 200 字摘要
第四阶段:压测 + 报告(Day 6–7)
- [ ] T11 (90min) 用你的框架重做第 17 节项目一(研究助手),证明框架能撑起真实任务
- [ ] T12 (60min) 可观测性验收:从 Tracer 的 JSON 完整回放一次任务的决策路径,画成时序图
- [ ] T13 (90min) README:架构图、三个核心机制的实现讲解、「我从造轮子里理解了什么」
🔨 关键骨架
import inspect, json, time
from typing import Callable
# ============ @tool 装饰器:自动生成 schema ============
def tool(fn: Callable):
sig = inspect.signature(fn)
params = {
name: {"type": "string", "desc": ""} # 简化:都当 string,真实版按注解映射
for name in sig.parameters
}
fn._schema = {
"name": fn.__name__,
"description": (fn.__doc__ or "").strip(),
"parameters": params,
}
return fn
# ============ 迷你 Agent ============
class MiniAgent:
def __init__(self, model, tools, max_steps=20, token_budget=50_000,
compact_at=0.75, timeout_s=300):
self.model = model
self.tools = {t._schema["name"]: t for t in tools}
self.max_steps = max_steps
self.token_budget = token_budget
self.compact_at = compact_at
self.timeout_s = timeout_s
self.messages = []
self.trace = []
def _est_tokens(self):
return sum(len(m["content"]) for m in self.messages) // 3 # 粗估
def compact(self):
"""历史 → 摘要,保留关键事实(框架的灵魂)"""
history = "\n".join(f"{m['role']}: {m['content']}" for m in self.messages)
prompt = (f"总结以下 Agent 工作历史,必须保留:已完成的事、当前状态、"
f"下一步计划、所有关键事实(文件路径/决策/报错原文)。\n\n{history}")
summary = self.model.chat([{"role": "user", "content": prompt}])
# 只保留 system + 摘要,历史被压缩掉
system = self.messages[0]
self.messages = [system, {"role": "user",
"content": f"【历史摘要】\n{summary}\n\n请继续。"}]
self.trace.append({"event": "compact", "summary_len": len(summary)})
def run(self, task, system_prompt):
self.messages = [{"role": "system", "content": system_prompt},
{"role": "user", "content": task}]
start = time.time()
for step in range(self.max_steps):
if time.time() - start > self.timeout_s:
return self._finish("超时护栏触发", step)
if self._est_tokens() > self.token_budget * self.compact_at:
self.compact()
reply = self.model.chat(self.messages)
self.messages.append({"role": "assistant", "content": reply})
call = self._parse_tool_call(reply)
if call is None:
self.trace.append({"step": step, "type": "final", "content": reply})
return reply
tool_fn = self.tools.get(call["tool"])
result = (tool_fn(**call["args"]) if tool_fn
else f"错误:无 {call['tool']} 工具")
self.messages.append({"role": "user", "content": f"工具结果:\n{result}"})
self.trace.append({"step": step, "type": "tool",
"call": call, "result": result[:200]})
return self._finish("达到最大步数", self.max_steps)
def spawn_subagent(self, task, tools, system_prompt):
"""派生子 Agent:干净上下文,只回摘要"""
sub = MiniAgent(self.model, tools, max_steps=self.max_steps)
result = sub.run(task, system_prompt)
self.trace.append({"event": "subagent", "task": task[:80],
"sub_steps": len(sub.trace)})
return result # 只有这个摘要回到主 Agent,子 Agent 的 8000 字历史被丢弃
def _parse_tool_call(self, reply):
try:
c = json.loads(reply.strip())
return c if "tool" in c else None
except json.JSONDecodeError:
return None
def _finish(self, reason, step):
self.trace.append({"event": "halt", "reason": reason, "step": step})
return f"[{reason}] 已完成步骤:{step}"
🕳️ 专属坑
| 坑 | 说明 |
|---|---|
| 压缩丢关键信息 ⭐ | 摘要提示词不够具体 → Agent 压缩后忘了文件路径重新犯错。摘要必须列明「关键事实」清单 |
| token 估算不准 | 字符/3 只是粗估,靠它做预算会有偏差。够用即可,别陷进精确 tokenize |
| 子 Agent 无限递归 | 子 Agent 又派生子 Agent…… 加一个 depth 上限 |
| 护栏触发后崩溃 | 超时/超步数要优雅返回已有进展,不是抛异常 |
| Tracer 太啰嗦或太简略 | 记全每步的输入输出会爆,只记 type/call/result 前 200 字 + token |
| MockModel 太假 | 让 MockModel 至少能按工具 schema 生成合法调用,否则测不出真问题 |
🔗 这一章连到哪里
| 去哪 | 为什么 |
|---|---|
| AI基础设施 16 | ⭐ 第二阶段「上下文管理」为什么能省钱:前缀缓存在显存里的物理形态 |
| 全景导论 06 | 你在框架里调的那些旋钮,底层对应哪几件武器 |
| 上线之后 17 | ⚠️ 第四阶段的压测报告要能复现 —— 否则数字没人信 |
👉 做完这个项目再去看 16b · 框架这层皮:你手搓出来的每一块,正好对得上市面框架的一个模块 —— 那份对照表会让这个项目的价值翻倍。
✅ 通关标准
@tool装饰器能自动生成 schema,加工具只需一行注解- 20+ 轮的长任务会自动触发压缩,且压缩后不丢关键信息、能继续正确工作
- 子 Agent 读大量内容后,主 Agent 上下文只增加摘要那点量(可从 token 曲线看出)
- 三道护栏都能优雅触发(造极端输入验证)
- 能从 Tracer 的 JSON 完整回放一次任务
🏆 加分
- 加提示缓存意识:把稳定的 system prompt 放最前,动态内容放后(第 12 节)
- 实现一个
@tool的防呆版本:参数校验失败返回可行动的错误 - 支持并行子 Agent(第 17 节项目三就能直接用你的框架跑)
✅ 检查点
@tool装饰器要自动生成什么?为什么值得花力气做这一步?- Agent 主循环里的三道护栏分别防什么?
compact()压缩上下文时,摘要里必须保留什么?为什么?- 子 Agent 的核心价值是什么?主 Agent 的上下文应该只增加多少?
- Tracer 为什么要输出结构化 JSON 而不是打日志?
- 做完这个项目之后,你看框架源码的感受会有什么变化?
👀 答案
- 自动从函数签名和 docstring 生成 JSON schema。值得做是因为加一个工具的成本决定了你会加多少工具——手写 schema 时你会懒得加,一行注解时你会自然地把能力做全。
- ①最大轮数(防死循环)②最大 token / 预算(防成本失控)③重复调用检测(防它在同一个工具上反复撞墙)。
- 关键事实清单——已确认的结论、已做的决定、还没解决的问题、文件路径这类具体值。因为压缩是有损的,而"损掉的恰好是关键事实"是最常见的失败。摘要写得再流畅,丢了一个文件路径就得重新查一遍。
- 隔离上下文——子 Agent 读一大堆内容,主 Agent 只拿到结论。主 Agent 的上下文只应增加摘要那点量(可以从 token 曲线明显看出来)。
- 因为结构化才能回放和分析。打日志只能"看",JSON trace 能:按步骤回放、统计各工具调用次数和耗时、自动比对两次运行的差异、喂给评测脚本。🔗 这就是第 13 章 LLMOps说的可观测性。
- 从"框架很神秘"变成"框架就是我写的这几个机制 + 一堆适配器和边界处理"。之后你会更容易判断"这个框架值不值得用",而不是被它的抽象吓住。
🛑 可以停在这里
⚡ 走神救援
5–7天,从裸HTTP请求开始徒手造Agent框架,不许用任何框架/SDK——把前11节的概念焊死成代码:读十遍「上下文压缩」不如自己写一次触发压缩的那个if。五块黑箱:⭐@tool装饰器从类型注解和docstring自动生成schema(加一个工具的成本决定了你会加多少工具);run()主循环+三道护栏:最大轮数防死循环/token预算防成本失控/重复调用检测防在同一工具上反复撞墙,⚠️护栏触发要优雅退出返回已有进展,不是抛异常;⭐⭐compact()是精华——粗估token(字符数除以3或4就够,⚠️别陷进精确tokenize)超过预算的75%就总结历史,摘要必须显式保留「已完成/当前状态/下一步/关键事实(路径、决策、报错)」,因为压缩是有损的,"损掉的恰好是关键事实"是最常见的失败;spawn_subagent()开干净上下文、只回摘要,验收很具体:子Agent读了8000字,主Agent上下文只增加那200字摘要,⚠️加depth上限防无限递归;Tracer输出结构化JSON而不是打日志——⭐结构化才能按步回放、统计调用次数和耗时、比对两次运行差异、喂给评测脚本,⚠️只记type/call/result前200字+token。节奏:Day1–2循环与工具(先用MockModel零成本跑通,⚠️太假就测不出真问题),Day3–4上下文管理,Day5子Agent,Day6–7重做研究助手项目+从Tracer回放。加分:system prompt放最前做提示缓存、@tool防呆版、并行子Agent。做完顿悟「原来LangChain就这」——用任何框架都知道皮下是什么。
下一个挑战 👉 19-挑战项目B-评测驱动开发.md