3
0

2.Agent初尝试(无依赖的LLM代理框架,离线Agent)

2026-10-12
2026-10-12

1. 项目简介

项目:GitHub - qi-fg/miniagent:🌱一个小巧、无依赖的LLM代理框架:几百行Python中包含ReAct循环、工具和内存。·GitHub

这是一个轻量 Python Agent 项目,用来演示 ReAct 风格(模型推理(Reason)和采取(Act)行动交替进行,工具结果再做观察交回(Observation)模型.)的工具调用流程。离线运行时可以使用 MockProvider,不需要 API Key。

ReAct 是 Reason、Act、Observation 的循环:

  • Reason:模型决定下一步做什么。

  • Act:模型请求 Agent 调用某个工具。

  • Observation:Agent 执行工具,把结果交回模型。

  • 模型可以根据结果继续调用工具,也可以直接给出最终答案。

在这个项目里,工具调用通过普通文本表示,例如:

Thought: I need to evaluate the expression.
Action: calculator
Action Input: 123 * 456

这是一种简单的文本格式。Agent 解析 Action 和 Action Input 后,查找并运行对应工具。如果模型给出 Final Answer:,Agent 就直接返回答案。

2. 项目文件地图

文件

主要作用

miniagent/agent.py

Agent 主循环、解析工具调用、执行工具

miniagent/tools.py

@tool 装饰器、工具对象和工具注册表

miniagent/memory.py

保存用户输入、模型回复和工具结果

miniagent/providers.py

Mock 和真实模型 Provider

miniagent/cli.py

命令行程序和交互入口

examples/basic.py

展示一次完整工具调用和对话记录

tests/test_agent.py

离线测试 Agent 行为

3. 如何运行

在 PowerShell 中进入项目目录,并先设置 Python 路径:

Set-Location "C:\Users\chen\Documents\agent-learning\miniagent-upstream"
$python = ".\.venv\Scripts\python.exe"

用 MockProvider 运行一次计算:

& $python -m miniagent.cli "Calculate 123 * 456" --provider mock

启动交互模式:

& $python -m miniagent.cli --provider mock --interactive

出现 you> 后输入内容并按回车。输入 exit 退出。

运行示例和测试:

& $python examples\basic.py
& $python tests\test_agent.py

当前测试结果:Ran 7 tests,OK。

注意:$python 要先赋值,再运行命令。若在新开的 PowerShell 窗口中,之前设置的变量可能不存在。项目的 CLI 请通过 -m miniagent.cli 启动;直接运行 miniagent\cli.py 会导致相对导入失败。

4. 一次计算的调用流程

例如用户输入:

Calculate 123 * 456

调用过程大致如下:

用户输入
  → Agent 把输入保存到 Memory
  → Agent 请求 Provider
  → MockProvider 返回 calculator 工具调用
  → Agent 解析 Action 和 Action Input
  → Agent 从 ToolRegistry 找到 calculator
  → Agent 执行计算器
  → Agent 把 Observation 保存到 Memory
  → Agent 再次请求 Provider
  → Provider 根据工具结果生成最终回答

max_steps 限制 Agent 最多循环多少步,避免它一直执行下去。

5. MockProvider 与真实 Provider

MockProvider 是模拟模型的离线实现。它会根据输入中的关键词和算式生成预设格式的回复,适合学习和测试流程,但它不是通用大语言模型。

它能模拟 search 工具调用,不代表项目真的有搜索功能。CLI 当前注册了计算器工具,没有注册真正的搜索工具,所以 Agent 找不到 search 时会返回工具不可用的信息。

真实的 OpenAI、Anthropic 或 Ollama Provider 则负责把消息发给相应模型。它们和 MockProvider 都通过 Provider 接口接入 Agent。

6.tests/test_agent.py

@tool
def calculator(expr: str) -> float:
    """Evaluate a basic arithmetic expression (+, -, *, /, ^, %, parentheses)."""
    import ast
    import operator

    ops = {
        ast.Add: operator.add, ast.Sub: operator.sub, ast.Mult: operator.mul,
        ast.Div: operator.truediv, ast.Pow: operator.pow, ast.BitXor: operator.pow, ast.Mod: operator.mod,
        ast.FloorDiv: operator.floordiv, ast.USub: operator.neg, ast.UAdd: operator.pos,
    }

    def _eval(node):
        if isinstance(node, ast.Expression):
            return _eval(node.body)
        if isinstance(node, ast.Constant) and isinstance(node.value, (int, float)):
            return node.value
        if isinstance(node, ast.BinOp):
            return ops[type(node.op)](_eval(node.left), _eval(node.right))
        if isinstance(node, ast.UnaryOp):
            return ops[type(node.op)](_eval(node.operand))
        raise ValueError("unsupported expression")

    return _eval(ast.parse(expr, mode="eval"))

def calculator()是准备运算表,遇到ast.Add会调用operator.add 同理ast.Mul表示乘法等

def _eval() 按语法树逐层计算表达式,ast.Expression:表达式最外层的包装,真正内容在 node.body,所以继续往里算。

ast.Constant:数字常量,比如 2 或 3.5,直接返回数字。

ast.BinOp:双目运算,比如 2 + 3。它分别计算左边和右边,再用之前的 ops 表查出对应运算并执行。

ast.UnaryOp:单目运算,比如 -2。先计算操作数,再执行负号等操作。

其他节点:当前计算器不支持,就抛出 ValueError

class TestAgentLoop(unittest.TestCase):
    def _agent(self) -> Agent:
        return Agent(
            provider=MockProvider(),
            tools=ToolRegistry([calculator]),
            memory=Memory(),
            max_steps=4,
        )

    def test_calculator_tool_is_called(self):
        out = self._agent().run("Calculate 123 * 456")
        self.assertIn("56088", out)

    def test_unknown_tool_is_handled(self):
        # MockProvider only emits calculator/search; with no tools it must not crash.
        agent = Agent(MockProvider(), tools=ToolRegistry(), memory=Memory(), max_steps=3)
        out = agent.run("Calculate 9 * 9")
        self.assertIsInstance(out, str)
        self.assertTrue(len(out) > 0)

    def test_memory_records_observation(self):
        agent = self._agent()
        agent.run("Calculate 2 + 3")
        roles = [m["role"] for m in agent.memory.get_context()]
        self.assertIn("system", roles)  # Observation line was appended

    def test_mock_provider_uses_observation_from_current_turn(self):
        agent = self._agent()
        self.assertIn("2", agent.run("Calculate 1 + 1"))
        self.assertIn("4", agent.run("Calculate 2 + 2"))

这是一组测试代码,定义一组Agent,验证Agent能不能通过计算器工具算出123*456=56088

继承 unittest.TestCase 后,测试框架能识别并运行以 test_ 开头的方法。

def _agent (self) ->Agent这是测试用的辅助方法,用来创建一个 Agent。开头的 _ 表示它是辅助方法,不会被当成测试单独运行。

创建 Agent 时传入了:

MockProvider():模拟模型回复,离线测试不用 API Key。

ToolRegistry([calculator]):让 Agent 能调用计算器。

Memory():保存对话记录。

max_steps=4:最多执行 4 步,避免循环过久。

def test_calculator_tool_is_called() 如果结果字符串里包含 56088,测试就通过。它验证的是Agent 最终算出了预期结果

def test_unknown_tool_is_handled() 检查的是Agent 要调用的工具不存在时,程序会不会崩溃。这里 ToolRegistry() 是空的,所以 Agent 没有可用工具。MockProvider 收到“Calculate 9 * 9”后仍会尝试调用 calculator。Agent 找不到它时,会把错误作为文本处理。

def test_memory_records_observation()检查的是:调用工具后,Agent 有没有把工具结果保存到对话记忆里。角色大致是 ["user", "assistant", "system", "assistant"]:用户输入、模型的工具调用、工具结果、模型的最终回答。最后,self.assertIn("system", roles) 检查记忆里是否出现过 system 角色。这里是有效的,因为 Memory 初始为空,而 Agent 用 system 角色保存工具 Observation。

def test_mock_provider_uses_observation_from_current_turn()所以这项测试关注的重点是连续对话时正确区分每一轮的工具结果。

def test_tool_description_uses_first_docstring_line(self):
        @tool
        def example_tool(value: str) -> str:
            """Short summary.

            Longer implementation details should stay out of the model prompt.
            """
            return value

        registry = ToolRegistry([example_tool])
        self.assertEqual(registry.get("example_tool").description, "Short summary.")

    def test_parse_action_extracts_tool_and_input(self):
        agent = self._agent()
        action, action_input = agent._parse_action(
            "Thought: calculate the result\n"
            "Action: calculator\n"
            "Action Input: 12 * 34"
        )
        self.assertEqual(action, "calculator")
        self.assertEqual(action_input, "12 * 34")

    def test_parse_action_returns_none_when_no_action(self):
        self.assertEqual(self._agent()._parse_action("Final Answer: done"), (None, None))

def example_tool(value: str) -> str 这个测试验证:工具的描述只取函数文档字符串的第一行,避免把详细说明也塞进模型提示词。 registry = ToolRegistry([calculator])意思是把 calculator 加进工具目录。Agent 运行时会:

  1. 把目录里的工具描述提供给模型,让模型知道可以选什么。

  2. 模型返回 Action: calculator 后,通过注册表按名称找到 calculator,再执行它。

所以 ToolRegistry 像工具的目录或菜单:负责登记和查找工具,不负责计算本身。ToolRegistry() 不带工具时,目录就是空的,Agent 就找不到 calculator。

def test_parse_action_extracts_tool_and_input() 验证能不能从模型回复中拆出工具名和工具参数。

def test_parse_action_returns_none_when_no_action() 检查输入里没有 Action 和 Action Input,所以 _parse_action() ,self.assertEqual(...) 检查实际返回值是否正好等于这个二元组。

if __name__ == "__main__":
    unittest.main()

直接运行这个测试文件时,启动 unittest 来运行测试。

7. @tool 和 ToolRegistry

@tool 把普通函数包装成 Agent 可以使用的工具,并从函数信息中取得工具名称和描述。

例如:

registry = ToolRegistry([calculator])

ToolRegistry 是工具目录,负责登记工具并按名称查找。它也可以把工具名称和描述整理出来,放进发给模型的提示词中。

Agent 收到:

Action: calculator

后,会按名称从注册表找到 calculator 并执行它。注册表负责登记和查找,不负责计算本身。ToolRegistry() 不带参数时,注册表为空。

工具描述测试中的注册代码是:

registry = ToolRegistry([example_tool])

8. tests/test_agent.py 测试了什么

测试类继承 unittest.TestCase。以 test_ 开头的方法会被 unittest 识别为测试。

_agent() 是测试辅助方法,用于创建带有 MockProvider、计算器、Memory 和步数限制的 Agent。开头的下划线表示这是辅助方法,不是一个测试。

测试

检查内容

test_calculator_tool_is_called

输入计算请求,检查答案是否包含预期结果 56088。这是通过结果间接验证计算流程。

test_unknown_tool_is_handled

使用空注册表,检查找不到工具时 Agent 是否仍返回非空字符串。它没有检查错误文本是否完全正确。

test_memory_records_observation

运行计算后检查 Memory 中是否出现 system 角色。项目用 system 消息保存工具 Observation。

test_mock_provider_uses_observation_from_current_turn

同一个 Agent 连续计算 1 + 1 和 2 + 2,检查第二轮不会误用第一轮结果。

test_tool_description_uses_first_docstring_line

检查工具描述只取文档字符串第一行。

test_parse_action_extracts_tool_and_input

检查 _parse_action() 能否解析出工具名和参数。

test_parse_action_returns_none_when_no_action

检查没有工具调用时,解析方法是否返回 (None, None)。

例如,工具描述测试定义了一个临时工具:

@tool
def example_tool(value: str) -> str:
    """Short summary.

    Longer implementation details should stay out of the model prompt.
    """
    return value

然后断言工具描述恰好是 "Short summary."。这个测试关注描述,不关注工具返回值。

解析测试传入模拟的模型回复:

Thought: calculate the result
Action: calculator
Action Input: 12 * 34

预期解析结果为:

action == "calculator"
action_input == "12 * 34"

测试文件末尾的代码:

if __name__ == "__main__":
    unittest.main()

意思是直接运行这个测试文件时,启动 unittest 并执行里面的测试。

评论