🏠 总目录📚 本教程 挑战A · 手搓框架
📑 本页目录(点开跳转)

挑战项目 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)

第二阶段:上下文管理(Day 3–4)—— 框架的精华

第三阶段:子 Agent(Day 5)

第四阶段:压测 + 报告(Day 6–7)


🔨 关键骨架

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 · 框架这层皮:你手搓出来的每一块,正好对得上市面框架的一个模块 —— 那份对照表会让这个项目的价值翻倍。



✅ 通关标准

  1. @tool 装饰器能自动生成 schema,加工具只需一行注解
  2. 20+ 轮的长任务会自动触发压缩,且压缩后不丢关键信息、能继续正确工作
  3. 子 Agent 读大量内容后,主 Agent 上下文只增加摘要那点量(可从 token 曲线看出)
  4. 三道护栏都能优雅触发(造极端输入验证)
  5. 能从 Tracer 的 JSON 完整回放一次任务

🏆 加分


✅ 检查点

  1. @tool 装饰器要自动生成什么?为什么值得花力气做这一步?
  2. Agent 主循环里的三道护栏分别防什么?
  3. compact() 压缩上下文时,摘要里必须保留什么?为什么?
  4. 子 Agent 的核心价值是什么?主 Agent 的上下文应该只增加多少?
  5. Tracer 为什么要输出结构化 JSON 而不是打日志?
  6. 做完这个项目之后,你看框架源码的感受会有什么变化?
👀 答案
  1. 自动从函数签名和 docstring 生成 JSON schema。值得做是因为加一个工具的成本决定了你会加多少工具——手写 schema 时你会懒得加,一行注解时你会自然地把能力做全。
  2. 最大轮数(防死循环)②最大 token / 预算(防成本失控)③重复调用检测(防它在同一个工具上反复撞墙)。
  3. 关键事实清单——已确认的结论、已做的决定、还没解决的问题、文件路径这类具体值。因为压缩是有损的,而"损掉的恰好是关键事实"是最常见的失败。摘要写得再流畅,丢了一个文件路径就得重新查一遍。
  4. 隔离上下文——子 Agent 读一大堆内容,主 Agent 只拿到结论。主 Agent 的上下文只应增加摘要那点量(可以从 token 曲线明显看出来)。
  5. 因为结构化才能回放和分析。打日志只能"看",JSON trace 能:按步骤回放、统计各工具调用次数和耗时、自动比对两次运行的差异、喂给评测脚本。🔗 这就是第 13 章 LLMOps说的可观测性。
  6. 从"框架很神秘"变成"框架就是我写的这几个机制 + 一堆适配器和边界处理"。之后你会更容易判断"这个框架值不值得用",而不是被它的抽象吓住。

🛑 可以停在这里

走神救援

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

打卡记录保存在你的浏览器里,首页能看到总进度