2.Agent初尝试(无依赖的LLM代理框架,离线Agent)
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. 项目文件地图
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 运行时会:
把目录里的工具描述提供给模型,让模型知道可以选什么。
模型返回
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。开头的下划线表示这是辅助方法,不是一个测试。
例如,工具描述测试定义了一个临时工具:
@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 并执行里面的测试。