一个轻量级 LLM Agent 框架的设计之旅——从外部依赖剥离、工厂模式重构,到命令行工具交付。


0. 起点:我们要解决什么问题?

市面上有不少 Agent 框架——LangChain、AutoGen、CrewAI,每一个拿出来都是庞然大物。它们当然强大,但强大是有代价的:你还没来得及想清楚自己的 Agent 该怎么推理,先要啃完几百页文档,搞清楚人家的抽象体系。我不反对抽象,但我反对在你还没摸到问题本质之前就被抽象绑架。

所以我们定了几个很朴素的目标:做一个轻量、可控、支持多种推理范式的 Python Agent 框架,配一个开箱即用的 CLI。 注意"轻量"不是"简陋"——轻量意味着每一行代码都有存在的理由,每一个抽象都经得起追问。

核心要求拆开来看其实就四件事:

  • 多范式支持:简单对话、ReAct 推理、反思改进、计划求解——四种设计方法,按需选用。不是"我们提供一种范式让你学",而是"四种都给你,你自己体会它们的差异"。
  • 工具可插拔:搜索、计算等工具通过注册表注入,不只属于某一种 Agent。工具是能力,不是身份。
  • 低耦合:LLM 客户端、消息管理、工具系统各自独立。改 LLM 调用方式不应该波及 Agent 逻辑,反之亦然。
  • 开箱即用:一个 python run.py 就能启动交互式对话。不需要先理解依赖注入才能跑起来。

最终交付的项目结构:

ha_core/
├── core/           # LLM客户端、消息模型、Builder工厂、配置、异常
├── agents/         # SimpleAgent, ReActAgent, ReflectionAgent, PlanAndSolveAgent, AutoAgent
├── tools/          # 工具基类、注册中心、内置工具(计算器、搜索)
└── cli/            # 交互式命令行

这个结构本身就在说话:core 不依赖 agents,agents 不依赖 cli。每一层只向下看。


1. 第一刀:自包含的 LLM 客户端——核心层的底座

整个框架的底座是什么?是 LLM 调用。每一次 Agent 推理、每一次工具执行、每一个 token 的进出,都要经过这一层。底座不能是别人的——它必须是自己的,必须零外部依赖,必须每一行代码你都看得见、改得动。

想清楚这一点之后,MyLLM 的设计就自然浮现了。它只做三件事,但每件事都是后续所有 Agent 的基础:

class MyLLM:
    """自包含的 LLM 客户端——整个框架唯一直接调用 OpenAI API 的地方"""
    def think(self, messages): ...        # 流式返回,适合实时展示
    def invoke(self, messages): ...       # 非流式返回,适合内部调用
    def invoke_with_tools(self, messages, tools): ...  # Function Calling,工具循环的核心

三个方法,三个使用场景。think() 给需要流式输出的地方(CLI 实时打字),invoke() 给不需要流式的地方(AutoAgent 的分类调用、ReflectionAgent 的反思环节),invoke_with_tools() 给工具循环——也就是所有 Agent 共用 _tool_loop() 的那个入口。

这里有一个一开始容易忽略的考量:为什么是三个方法,而不是一个方法加参数? 直觉上你可能会写一个 generate(messages, stream=False, tools=None) 统一入口。但用过 OpenAI SDK 的人都知道——流式和非流式的返回类型完全不同(一个是 iterator,一个是 completion 对象),Function Calling 的请求构造逻辑也和非工具调用不同。强行揉进一个方法,内部的 if-else 分支会迅速膨胀,每一个分支都在为不同的调用方服务。三个独立方法,调用方不需要关心自己不需要的参数,方法内部也不需要判断"我这次是被谁调用的"。

更深一层想:核心层的依赖方向应该单向且向下。 整个 core/ 包的 import 方向是 LLM → Message → Builder → Config → Exception,没有任何一层向上引用。LLM 客户端作为最底层,只依赖 openai SDK——而 openai 是唯一一个被允许进入核心层的外部依赖,因为替换它的代价和替换 LLM 提供商本身一样大,这是我们可以接受的边界。

旁敲侧击一句:软件开发里有种焦虑叫"不要重复造轮子"。这句话本身没错,但容易被滥用成"永远不要自己写任何东西"。实际上,核心路径上的轮子值得自己造——不是因为你觉得比别人聪明,而是因为核心路径上的控制权就是生存权。边缘能力可以依赖,核心能力必须自主。这条边界线画在哪里,往往是架构里最重要的一个判断。


2. 第二刀:存储与构建分离 —— MessageBuilder 工厂

最早 Agent 基类里有一个 _build_messages() 方法。看起来挺合理——构建消息嘛,Agent 总得有个方法把历史记录拼成 LLM 能理解的格式。

但多看两眼就发现不对劲:SimpleAgent 不用它(直接用 history.to_openai_format()),ReActAgent 也不用(自己写了一套 prompt 模板)。基类的方法,一半子类绕过去不用——这就是 Martin Fowler 说的"拒绝继承"(Refused Bequest),继承体系里的经典坏味道。

停下来想了一下:为什么子类不愿意用基类的 _build_messages() 因为它们对"消息该怎么拼"这件事的理解完全不同。SimpleAgent 觉得就是 system + 对话历史 + 当前输入;ReActAgent 觉得要注入 ReAct 格式的 prompt 模板;ReflectionAgent 觉得反思阶段的消息结构和生成阶段根本是两回事。

换句话说——"存储"和"构建"是两件不同的事,硬塞进一个方法里就是在制造矛盾。 存储关心的是"我记了什么",构建关心的是"我怎么把记下来的东西展示给 LLM"。前者是数据问题,后者是策略问题。

于是拆成两个概念:

  • MessageHistory(存储):纯数据容器,add / clear / to_dict。不关心 LLM,不关心 prompt 格式,只忠实地记录。
  • MessageBuilder(构建策略):从 history + 输入构建 LLM 消息列表。每个 Agent 选自己的策略。

工厂模式派发:

                  ┌─────────────────┐
                  │  MessageBuilder │  (ABC)
                  └────────┬────────┘
        ┌──────────────────┼──────────────────┐
        │         │        │        │         │
   Conversation  ToolCall SingleTurn Reflection ReAct
   Builder      Builder  Builder   Builder    Builder
   (Simple)     (Tool)   (Plan)   (Reflect)   (ReAct)

每个 Agent 初始化时选择自己的 Builder,不强制,但有一个合理的默认值:

class SimpleAgent(Agent):
    def __init__(self, *args, **kwargs):
        kwargs.setdefault("builder", ConversationBuilder())
        super().__init__(*args, **kwargs)

    def run(self, input_text):
        messages = self.builder.build_for_llm(self._history, input_text)
        response = self._llm_generate(messages)
        self.builder.record_turn(self._history, input_text, response)
        return response

builder 只暴露两个核心方法:

  • build_for_llm(history, input, extra_context) → 构建消息列表
  • record_turn(history, user_input, assistant_output, tool_calls) → 记录轮次

一个有意思的细节:record_turn 也交给 builder 而不是写死在基类,是因为不同范式对"一轮对话"的定义不一样。SimpleAgent 一轮就是一问一答;ReActAgent 一轮可能包含多次工具调用,记录方式完全不同。把记录策略也放权给 builder,是同样的逻辑在另一个方向上的延伸。

这件事教会我一个判断标准:当你发现基类的方法被子类选择性绕过时,不是子类的问题——是你的抽象切错了维度。 不是"消息构建"不应该存在,而是它不应该和"消息存储"绑在同一个对象上。找到正确的切面,子类自然会拥抱基类的方法,而不是绕着走。


3. 第三刀:Tools 不是你 Agent 的专利

最初的设计里有 5 个 Agent 文件,其中 ToolAgent 是一个独立的"工具调用 Agent"。这个命名暴露了一个隐含的假设:"使用工具"被当成了一种设计方法,和 ReAct、Reflection 并列。

但仔细想想——ReActAgent 不用工具吗?它每一步都在调工具。ReflectionAgent 不用工具吗?它在生成和改进阶段都可以调。那为什么单独拎出一个 ToolAgent?说白了,ToolAgent 就是一个带工具循环的 SimpleAgent,它没有引入任何新的推理范式。把"工具调用"和"设计方法"混为一谈,是概念模型出了问题。

真正的问题不是"谁拥有工具",而是"工具调用能力应该怎么分配"。答案是:所有 Agent 共享,按需启用,按需禁用。

四种真正的设计方法

设计方法 核心模式 Tools 的角色
SimpleAgent 一问一答 每轮对话可调工具获取信息,但工具不是必需的
ReActAgent 思考→行动→观察循环 tools 是核心引擎——每一步都在推理 + 工具调用
ReflectionAgent 生成→自我批判→改进 生成和改进阶段可调工具;反思阶段必须禁用
PlanAndSolveAgent 先规划再分步执行 执行步骤可用工具;规划和汇总阶段禁用

注意"禁用"这个词出现了两次。工具不是越多越好——在错误的时间调用工具,比不调用工具更糟糕。 想象一下 ReflectionAgent 在自我批判阶段调了搜索工具:它本该审视自己的推理质量,结果被新的搜索结果带跑了注意力。这不是增强,是干扰。

解决方案分两步:

第一步:_tool_loop() 上移到 Agent 基类。

class Agent(ABC):
    def __init__(self, ..., tool_registry=None, max_tool_iterations=10):
        self.tool_registry = tool_registry  # None = 无工具模式

    def _llm_generate(self, messages, **kwargs):
        if self.tool_registry and self.tool_registry.count > 0:
            return self._tool_loop(messages, **kwargs)  # 自动走工具循环
        return self.llm.invoke(messages, **kwargs)       # 纯文本

    def _tool_loop(self, messages, **kwargs):
        """通用工具调用循环:发tools→执行→结果送回→循环直到回答"""
        for _ in range(self.max_tool_iterations):
            response = self.llm.invoke_with_tools(messages, tools=spec, ...)
            if not response.choices[0].message.tool_calls:
                return response.choices[0].message.content  # LLM 决定不再调工具,循环结束
            # 执行工具,结果追加到 messages,继续循环

_tool_loop() 本身很薄——它只是一个循环壳,不包含任何工具逻辑。所有工具能力仍然在 tools/ 包内。基类提供的是"什么时候调工具"的判断框架,不是"怎么调工具"的实现细节。这个分离很重要。

第二步:_without_tools() 上下文管理器。

@contextmanager
def _without_tools(self):
    saved, self.tool_registry = self.tool_registry, None
    try:
        yield
    finally:
        self.tool_registry = saved

# ReflectionAgent 中的使用
with self._without_tools():
    reflection = self._llm_generate(messages)  # 纯文本,不调工具

一个上下文管理器,临时将 tool_registry 置为 None。进入反思阶段时,Agent "忘记"自己有工具;退出反思阶段时,能力恢复。这个设计比传一个 use_tools=False 参数更干净——它不要求每个方法都感知"禁用工具"这件事,只在需要禁用的地方显式声明。

旁敲侧击:能力注入 vs 能力固化。 很多框架的做法是"ReActAgent 自带搜索工具,ReflectionAgent 不带工具"。这看起来很自然——不同类型有不同的能力集合嘛。但问题是:如果有一天你想让 ReflectionAgent 在生成阶段调用搜索工具呢?改源码。如果你想让 ReActAgent 在某些步骤禁用工具呢?改源码。把能力绑定在类型上,等于替用户做了所有取舍——而用户的需求永远比你想象的多样。 把能力变成可注入的、可动态开关的,成本很小(就一个 contextmanager),收益很大(所有组合都支持)。

同时删掉了 ToolAgent——它不是一种设计方法,只是一个带工具循环的 SimpleAgent。删掉它的那一刻,概念模型反而更清晰了。


4. 第四刀:正则解析 → 结构化输出

这一刀砍的不是代码,是一种思维惯性——总想把 LLM 当人看,用解析人类文本的方式解析它的输出。

ReActAgent:文本模板 → Function Calling

旧版 ReActAgent 的工作方式是:给 LLM 一个 prompt,告诉它"请按以下格式输出:Thought: ... Action: ...",然后拿正则表达式去提取。这套方案的问题不在于正则写得不好——正则写得再好也没用,因为 LLM 的输出本质上是概率性的。它大多数时候遵守格式,但偶尔会:

  • 多一个换行,正则的 (.+?) 吃到不该吃的内容
  • Action: 写成 Action:(中文冒号)
  • 在 Thought 里嵌套了一个"Thought:",非贪婪匹配截断了
  • 完全忘记格式,直接给了一段自然语言

每一个 edge case 你都可以修——加更多正则分支、加 strip、加 fallback。但这就像在用胶带补漏水的管子:每补一个洞,水压就会在另一个薄弱处冲开。根本问题是,你在和 LLM 的概率性对抗,而不是利用它的结构化能力。

新版直接用原生 Function Calling:

# 旧版:正则解析 LLM 文本——在概率性输出上做确定性解析,本质上就是矛盾的
thought_match = re.search(r"Thought:\s*(.+?)...", text, re.DOTALL)
action_match = re.search(r"Action:\s*(.+)", text)
# 失败率取决于 LLM 那一天的"心情"

# 新版:LLM 直接返回 tool_calls——结构化输出,没有解析这一步
response = self.llm.invoke_with_tools(messages, tools=spec)
for tc in response.choices[0].message.tool_calls:
    if tc.function.name == "finish":
        return tc.arguments["answer"]
    # 执行普通工具...
# 100% 结构化。格式问题不复存在,因为根本就没有"格式"这个概念了。

这里有一个思维转变值得展开:Function Calling 表面上是"让 LLM 调用工具"的机制,但它同时也是一个"让 LLM 输出结构化数据"的机制。 当你把 finish 定义为一个 tool,它的 arguments 就是 LLM 的结构化输出。你不再需要解析任何东西——LLM 直接告诉你答案是什么。

PlanAndSolveAgent:Python eval → JSON

旧版规划器让 LLM 输出 Python 列表 ["步骤1", "步骤2"],用 ast.literal_eval 解析。问题类似——LLM 输出的是"看起来像 Python 列表"的文本,而不是真正的 Python 列表。引号类型、中文标点、尾部逗号,每个都是潜在的解析炸弹。

新版让 LLM 输出纯 JSON,解析逻辑三层兜底:

# 旧 prompt: "输出 Python 列表: ['步骤1', '步骤2']"
# 新 prompt: "输出 JSON 数组: ["步骤1", "步骤2"]"

try:
    plan = json.loads(text)                # 1. 直接解析——大多数情况走这里
except:
    match = re.search(r'\[.*?\]', text)     # 2. 正则提取 JSON 片段再解析
    plan = json.loads(match.group(0))
    # 如果还失败,按行分割,每行去掉编号作为一步——最后的兜底

三层兜底看起来很"防御性",其实反映了一个务实的认知:即使你让 LLM 输出 JSON,它也不总是完美的。 但与正则解析自然语言不同的是,JSON 的"不完美"是可枚举的(多了前后文字、少了闭合括号),自然语言的"不完美"是开放的(措辞变化、格式漂移、脑补内容)。前者的 fallback 可以收敛,后者的 fallback 永远在增长。

这件事让我重新思考了"prompt engineering"的边界。 很多人花大量时间写更精妙的 prompt 让 LLM 输出更规范的格式——与其花时间驯服 LLM 的文本输出,不如换一个 LLM 本身就擅长且确定性的输出渠道。Function Calling 和 JSON Mode 就是这样的渠道。好的工程不是让不可靠的东西变可靠,而是找到本来就可靠的东西。


5. AutoAgent:让 LLM 选择 LLM 的使用方式

有了 4 种设计方法,一个问题自然浮现:用户怎么知道该选哪个?你可以写文档、画决策树、给示例——但最直接的方式是:让 LLM 自己判断。

这就是 AutoAgent——不是一种新的推理范式,而是一层路由:

class AutoAgent(Agent):
    CLASSIFY_PROMPT = """分析问题,判断最适合哪种方法:
    - simple: 简单问答、聊天、翻译
    - react: 需要搜索、多步推理、实时信息
    - reflection: 需要深度分析、写作、批判性思考
    - plan_solve: 需要拆解步骤的复杂问题、多阶段任务
    只回复一个词。问题: {question}"""

    def _classify(self, question: str) -> str:
        result = self.llm.invoke(
            [{"role": "user", "content": self.CLASSIFY_PROMPT.format(question=question)}],
            temperature=0.0  # 低温 = 确定性分类,不想要创造性
        )
        for mode in ["plan_solve", "reflection", "react", "simple"]:
            if mode in result.lower():
                return mode
        return "simple"  # 认不出来的都当简单对话处理

    def run(self, input_text):
        mode = self._classify(input_text)
        agent = create_agent(mode, ...)  # 创建对应 Agent
        return agent.run(input_text)

这段代码很简短,但背后有一个值得琢磨的设计选择:分类用的是一个极简的 prompt + temperature=0.0 的调用,而不是在 prompt 里塞满 few-shot 示例。 为什么?因为对分类任务来说,few-shot 示例是一把双刃剑——它教会 LLM 你的分类标准,但也锚定了 LLM 的判断范围。如果你的 few-shot 示例不够全面,LLM 会倾向于把新问题往示例的模式上靠,而不是独立判断。对于四分类问题,分类标准本身已经足够清晰,few-shot 是过度约束。

另一个细节:那个 temperature=0.0。分类不需要创造性——你希望同一个问题每次得到同样的分类。temperature 在这里不是"调参",是在表达意图:这不是一个需要发散的生成任务,这是一个需要收敛的判断任务。

AutoAgent 本质上是一种"元认知"——用 LLM 来决定怎么用 LLM。 这个递归意味让我觉得很有意思:框架的智能不只体现在 Agent 的推理能力上,还体现在它知道自己该用什么方式推理。这和一个有经验的工程师拿到问题后先判断"这个问题适合用什么方法解决"是同一回事。


6. CLI:把一切封装成交互工具

写框架的人容易陷入一种心态:代码写得漂亮就行了,用户怎么用是他们的事。但这不对——一个框架的"第一印象"不是它的 API 文档,是用户第一次跑起来看到的东西。 如果用户要读半小时文档才能打出第一个 hello world,你的框架已经在丢分了。

所以花了些功夫做了一个开箱即用的 CLI:

$ python run.py

  +==========================================+
  |        ha_core Agent CLI                 |
  +==========================================+
  |  1. SimpleAgent (简单对话)                |
  |  2. ReActAgent (推理行动)                 |
  |  3. ReflectionAgent (反思改进)            |
  |  4. PlanAndSolveAgent (计划求解)          |
  |  5. AutoAgent (自动选择)                  |
  |  0. 退出                                 |
  +==========================================+
  Select [1-5] or 0 to exit:

CLI 只做三件事,每件事都力求零配置:

  1. 自动初始化 LLM.env 加载 → provider 自动检测 → MyLLM() 一行搞定。用户不需要知道 provider 是什么,只需要一个 API key。
  2. 自动加载工具:CalculatorTool 必装(不需要外部 API),SearchTool 有 API key 就装,没有就跳过——不报错也不警告,静默降级。
  3. 对话循环:输入 → Agent.run() → 输出,内置 /clear /history /mode 命令。

核心不到 300 行,就是一个 _chat_loop

while True:
    user_input = input("You: ")
    if user_input.startswith("/"):
        handle_command(user_input)     # /quit, /clear, /mode, /history
    else:
        response = agent.run(user_input)
        print(f"Assistant: {response}")

“一个 while True 就够了”——这不是偷懒,是刻意为之。 CLI 的职责是桥接用户输入和 Agent 输出,不应该有自己的状态逻辑。所有复杂度都在 Agent 层解决,CLI 只是一个透明的管道。

有一个小坑值得提一嘴:Windows 终端编码。emoji 在 GBK 控制台会直接崩掉,没有任何 warning。加一个 _safe() 函数过滤非 ASCII 可打印字符就能防住。这种问题不属于任何"设计模式",但真正写代码的人都知道——在跨平台交付里,编码问题的杀伤力远远大于架构问题。 架构问题最多让代码不好改,编码问题直接让代码跑不起来。


7. 回顾:几条真正影响走向的判断

这篇文章写到这里,如果只让我留几句话,会是这些:

砍掉外部依赖不是因为"依赖不好",而是因为核心路径上的控制权不容让渡。边缘能力可以依赖,核心能力必须自主。这个判断标准比"尽量不依赖"更精确,也更实用。

MessageBuilder 的分离源于一个直觉——基类方法被一半子类绕过,不是子类的错,是你的抽象切错了维度。存储和构建是两个维度,硬捏在一起就会互相伤害。找到正确的切面,代码自己就理顺了。

Tools 上移 + _without_tools() 回答了一个更普遍的问题:能力和身份的关系。工具调用不是一种"身份"(“我是一个 Tool Agent”),而是一种"能力"(“我可以在需要时调用工具”)。把能力绑定在类型上就是在替用户做所有取舍——而用户永远比你想象的多样。注入 > 固化。

从正则到结构化输出不仅仅是换了一种解析方式。它背后是一个认知转变:不要和 LLM 的概率性对抗,去找到它本身就擅长且确定性的输出渠道。好的工程不是让不可靠的东西变可靠,而是找到本来就可靠的东西。

AutoAgent 的路由让我意识到框架的智能可以体现在"元认知"层面——不只是推理能力,还有选择推理方式的能力。一个简单的分类 prompt + 零 temperature,比复杂的 few-shot + 规则引擎更可靠,因为它更少假设。

CLI 的 300 行提醒我:用户的第一印象来自第一次跑通的体验,不是 API 文档的精美程度。一个 while True 循环 + 一个菜单,比你精心设计的依赖注入容器更能留住用户。

这些判断放在一起,有一个共同的主题:少即是多不是口号,是每一步都在追问"这个抽象真的需要吗?"、“这个能力应该属于谁?”、"这个东西在替用户做决定吗?"的结果。


8. 试试看

git clone <repo>
cd PythonProject3

# 配置 API Key
echo 'DEEPSEEK_API_KEY="your-key"' > .env

# 启动
python run.py

5 让 AutoAgent 帮你决定用哪种方法——或者直接 python run.py auto。然后问一个问题,看看它怎么分类、怎么推理、怎么回答。


写于 2026 年 7 月。一个下午,从砍掉一个外部依赖开始,到交付一个完整的 CLI 工具结束。代码量不大,但每一步选择都在追问同样的问题:这个东西到底属于谁?

Logo

小龙虾开发者社区是 CSDN 旗下专注 OpenClaw 生态的官方阵地,聚焦技能开发、插件实践与部署教程,为开发者提供可直接落地的方案、工具与交流平台,助力高效构建与落地 AI 应用

更多推荐