mirror of
https://github.com/tvytlx/ai-agent-deep-dive.git
synced 2026-04-03 15:44:49 +08:00
Improve agent documentation for teaching
This commit is contained in:
390
src/agt/agent.py
390
src/agt/agent.py
@@ -1,12 +1,52 @@
|
||||
"""
|
||||
教学版 Agent 核心文件。
|
||||
|
||||
本文件的目标不是追求最复杂、最完整的工程实现,而是:
|
||||
|
||||
1. 用尽量清晰的 Python 代码,演示一个 Agent 的核心结构
|
||||
2. 保持代码易读、易改、易扩展,方便教学与逐步演进
|
||||
3. 为后续接入真实 LLM、工具系统、Skills、记忆系统提供稳定骨架
|
||||
4. 在写法上尽量遵循清晰封装、低耦合、易测试的最佳实践
|
||||
|
||||
教学约定:
|
||||
- 所有文档与注释优先使用中文
|
||||
- 类、函数、重要属性都尽量提供清晰文档
|
||||
- 优先追求“容易理解”,再追求“功能丰富”
|
||||
- 允许用最小可用实现来表达结构,但要保留明确扩展点
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable
|
||||
from typing import Any, Callable, Iterator, Protocol
|
||||
|
||||
|
||||
@dataclass
|
||||
class Message:
|
||||
"""
|
||||
表示 Agent 运行时中的一条消息。
|
||||
|
||||
这是一种教学用的最小消息模型。真实产品中,消息通常会更复杂,
|
||||
可能还会包含:消息 ID、父子关系、时间戳、工具块、token 使用信息等。
|
||||
|
||||
属性:
|
||||
role:
|
||||
消息角色。
|
||||
常见值包括:
|
||||
- "user":用户消息
|
||||
- "assistant":模型回复
|
||||
- "tool_result":工具执行结果
|
||||
|
||||
content:
|
||||
消息正文内容。
|
||||
这里为了教学简化为纯文本。
|
||||
|
||||
meta:
|
||||
附加元数据。
|
||||
用于保存额外的结构化信息,例如 turn 编号、chunks、工具名等。
|
||||
"""
|
||||
|
||||
role: str
|
||||
content: str
|
||||
meta: dict[str, Any] = field(default_factory=dict)
|
||||
@@ -14,41 +54,306 @@ class Message:
|
||||
|
||||
@dataclass
|
||||
class ToolResult:
|
||||
"""
|
||||
表示工具执行后的结果。
|
||||
|
||||
属性:
|
||||
ok:
|
||||
表示工具执行是否成功。
|
||||
|
||||
content:
|
||||
工具返回给 Agent 的文本内容。
|
||||
在真实系统中,这里也可以扩展为结构化内容。
|
||||
|
||||
meta:
|
||||
工具执行附带的元数据。
|
||||
例如执行耗时、文件路径、命中条数等。
|
||||
"""
|
||||
|
||||
ok: bool
|
||||
content: str
|
||||
meta: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
class Tool:
|
||||
def __init__(self, name: str, description: str, handler: Callable[[dict[str, Any]], ToolResult]):
|
||||
"""
|
||||
表示一个可注册到 Agent 中的工具。
|
||||
|
||||
设计思路:
|
||||
- 工具本身只关心自己的名字、说明和处理函数
|
||||
- Agent 只依赖统一的 Tool 接口,而不关心具体工具细节
|
||||
- 这样可以降低 Agent 与具体工具实现之间的耦合
|
||||
|
||||
参数:
|
||||
name:
|
||||
工具名称,必须唯一。
|
||||
|
||||
description:
|
||||
工具用途说明。
|
||||
主要用于教学展示、调试和未来做工具提示词时使用。
|
||||
|
||||
handler:
|
||||
工具执行函数。
|
||||
输入为字典,输出为 ToolResult。
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
name: str,
|
||||
description: str,
|
||||
handler: Callable[[dict[str, Any]], ToolResult],
|
||||
):
|
||||
"""
|
||||
初始化一个工具对象。
|
||||
|
||||
参数:
|
||||
name:
|
||||
工具名称。
|
||||
|
||||
description:
|
||||
工具用途描述。
|
||||
|
||||
handler:
|
||||
真正执行工具逻辑的函数。
|
||||
"""
|
||||
self.name = name
|
||||
self.description = description
|
||||
self.handler = handler
|
||||
|
||||
def call(self, payload: dict[str, Any]) -> ToolResult:
|
||||
"""
|
||||
执行工具。
|
||||
|
||||
参数:
|
||||
payload:
|
||||
传给工具的输入参数。
|
||||
|
||||
返回:
|
||||
ToolResult:工具执行结果。
|
||||
"""
|
||||
return self.handler(payload)
|
||||
|
||||
|
||||
class LLMClient(Protocol):
|
||||
"""
|
||||
LLM 客户端协议。
|
||||
|
||||
这是一个非常重要的教学设计点:
|
||||
|
||||
我们不让 Agent 直接依赖某一个具体模型 SDK,
|
||||
而是先定义一个统一接口。这样以后无论接:
|
||||
|
||||
- OpenAI
|
||||
- Anthropic
|
||||
- LiteLLM
|
||||
- 本地模型
|
||||
- 假模型(Fake LLM)
|
||||
|
||||
都可以复用同一个 Agent 主体。
|
||||
|
||||
方法:
|
||||
stream_text:
|
||||
输入当前消息列表,返回一个文本分块迭代器。
|
||||
"""
|
||||
|
||||
def stream_text(self, messages: list[Message]) -> Iterator[str]:
|
||||
"""
|
||||
基于当前消息列表,流式返回文本分块。
|
||||
|
||||
参数:
|
||||
messages:
|
||||
当前上下文消息列表。
|
||||
|
||||
返回:
|
||||
一个字符串迭代器,每次 yield 一段文本。
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
class FakeLLMClient:
|
||||
"""
|
||||
教学用假模型客户端。
|
||||
|
||||
这个类的核心价值是:
|
||||
- 不依赖任何远程 API
|
||||
- 方便本地测试
|
||||
- 能模拟“流式输出”的基本行为
|
||||
- 未来可以被真实 LLM 客户端直接替换
|
||||
|
||||
当前行为非常简单:
|
||||
- 读取最后一条用户消息
|
||||
- 拼成一段固定风格的回复
|
||||
- 再把回复切成多个 chunk 流式返回
|
||||
"""
|
||||
|
||||
def stream_text(self, messages: list[Message]) -> Iterator[str]:
|
||||
"""
|
||||
根据消息列表流式生成文本。
|
||||
|
||||
参数:
|
||||
messages:
|
||||
当前消息列表。
|
||||
|
||||
返回:
|
||||
文本块迭代器。
|
||||
"""
|
||||
last_user_message = next(
|
||||
(m.content for m in reversed(messages) if m.role == "user"),
|
||||
"",
|
||||
)
|
||||
response = f"[fake-llm] 你刚才说的是:{last_user_message}"
|
||||
for chunk in self._chunk_text(response, size=8):
|
||||
yield chunk
|
||||
|
||||
@staticmethod
|
||||
def _chunk_text(text: str, size: int = 8) -> Iterator[str]:
|
||||
"""
|
||||
将一段文本切成多个小块,用于模拟流式返回。
|
||||
|
||||
参数:
|
||||
text:
|
||||
要切分的完整文本。
|
||||
|
||||
size:
|
||||
每个文本块的最大长度。
|
||||
|
||||
返回:
|
||||
文本块迭代器。
|
||||
"""
|
||||
for i in range(0, len(text), size):
|
||||
yield text[i : i + size]
|
||||
|
||||
|
||||
class Agent:
|
||||
def __init__(self) -> None:
|
||||
"""
|
||||
教学版 Agent 主类。
|
||||
|
||||
这是当前项目中最核心的对象。它负责:
|
||||
- 保存消息
|
||||
- 管理工具
|
||||
- 管理简单记忆
|
||||
- 调用 LLM
|
||||
- 运行最小主循环
|
||||
|
||||
当前版本是教学最小骨架,因此刻意保持简单。
|
||||
未来可以在这个类上继续演化:
|
||||
- 权限系统
|
||||
- system prompt
|
||||
- tool calling 协议
|
||||
- verification agent
|
||||
- transcript 持久化
|
||||
- context management
|
||||
|
||||
参数:
|
||||
llm:
|
||||
可注入的 LLM 客户端。
|
||||
如果不传,则默认使用 FakeLLMClient。
|
||||
|
||||
属性:
|
||||
messages:
|
||||
当前 Agent 的消息历史。
|
||||
|
||||
tools:
|
||||
已注册工具表,key 为工具名,value 为 Tool 实例。
|
||||
|
||||
memory:
|
||||
教学版最小记忆列表。
|
||||
当前只做演示用途。
|
||||
|
||||
max_turns:
|
||||
最大回合数,避免无限循环。
|
||||
|
||||
llm:
|
||||
当前绑定的 LLM 客户端实现。
|
||||
"""
|
||||
|
||||
def __init__(self, llm: LLMClient | None = None) -> None:
|
||||
"""
|
||||
初始化 Agent。
|
||||
|
||||
参数:
|
||||
llm:
|
||||
可选的 LLM 客户端。
|
||||
不传时使用默认的 FakeLLMClient。
|
||||
"""
|
||||
self.messages: list[Message] = []
|
||||
self.tools: dict[str, Tool] = {}
|
||||
self.memory: list[str] = []
|
||||
self.max_turns: int = 20
|
||||
self.llm: LLMClient = llm or FakeLLMClient()
|
||||
|
||||
def register_tool(self, tool: Tool) -> None:
|
||||
"""
|
||||
注册一个工具到 Agent 中。
|
||||
|
||||
参数:
|
||||
tool:
|
||||
要注册的工具对象。
|
||||
"""
|
||||
self.tools[tool.name] = tool
|
||||
|
||||
def add_message(self, role: str, content: str, **meta: Any) -> None:
|
||||
"""
|
||||
向消息历史中追加一条消息。
|
||||
|
||||
参数:
|
||||
role:
|
||||
消息角色。
|
||||
|
||||
content:
|
||||
消息文本内容。
|
||||
|
||||
**meta:
|
||||
任意附加元数据。
|
||||
"""
|
||||
self.messages.append(Message(role=role, content=content, meta=meta))
|
||||
|
||||
def remember(self, text: str) -> None:
|
||||
"""
|
||||
向简化记忆列表中加入一条记忆。
|
||||
|
||||
参数:
|
||||
text:
|
||||
要保存的记忆文本。
|
||||
"""
|
||||
self.memory.append(text)
|
||||
|
||||
def can_use_tool(self, tool_name: str) -> bool:
|
||||
"""
|
||||
判断某个工具当前是否可用。
|
||||
|
||||
当前教学版逻辑非常简单:
|
||||
- 只判断该工具是否已注册
|
||||
|
||||
未来可以扩展为:
|
||||
- 权限判断
|
||||
- allow / ask / deny
|
||||
- agent 角色限制
|
||||
|
||||
参数:
|
||||
tool_name:
|
||||
工具名称。
|
||||
|
||||
返回:
|
||||
bool:是否可用。
|
||||
"""
|
||||
return tool_name in self.tools
|
||||
|
||||
def load_skills(self, skills_dir: str | Path) -> list[str]:
|
||||
"""
|
||||
从目录中发现可用 Skills。
|
||||
|
||||
当前教学版规则:
|
||||
- 递归查找 `SKILL.md`
|
||||
- skill 名称使用其父目录名
|
||||
|
||||
参数:
|
||||
skills_dir:
|
||||
Skills 根目录路径。
|
||||
|
||||
返回:
|
||||
已发现的 skill 名称列表。
|
||||
"""
|
||||
skills_path = Path(skills_dir)
|
||||
if not skills_path.exists():
|
||||
return []
|
||||
@@ -58,13 +363,62 @@ class Agent:
|
||||
loaded.append(path.parent.name)
|
||||
return loaded
|
||||
|
||||
def call_llm_stream(self) -> Iterator[str]:
|
||||
"""
|
||||
调用当前绑定的 LLM 客户端,并返回流式文本块。
|
||||
|
||||
这是一个关键的抽象层。
|
||||
以后接真实模型时,优先改的是 LLMClient 的实现,
|
||||
而不是 Agent 主循环本身。
|
||||
|
||||
返回:
|
||||
文本块迭代器。
|
||||
"""
|
||||
return self.llm.stream_text(self.messages)
|
||||
|
||||
def model_step(self) -> dict[str, Any]:
|
||||
"""
|
||||
执行一次模型步骤。
|
||||
|
||||
当前教学版逻辑:
|
||||
- 调用 LLM 流式接口
|
||||
- 收集所有 chunk
|
||||
- 组装为一条 message 类型结果
|
||||
|
||||
在未来版本中,这里可以继续扩展为:
|
||||
- message
|
||||
- tool_call
|
||||
- subagent_call
|
||||
- verification_request
|
||||
|
||||
返回:
|
||||
一个描述“本轮模型决定”的字典。
|
||||
"""
|
||||
chunks = list(self.call_llm_stream())
|
||||
return {
|
||||
"type": "message",
|
||||
"content": "这是一个教学用 Agent 骨架。下一步请在 model_step() 中接入你的模型逻辑。",
|
||||
"content": "".join(chunks),
|
||||
"chunks": chunks,
|
||||
}
|
||||
|
||||
def run(self, user_input: str) -> str:
|
||||
"""
|
||||
运行 Agent 的最小主循环。
|
||||
|
||||
当前版本流程:
|
||||
1. 记录用户输入
|
||||
2. 调用 model_step()
|
||||
3. 如果返回 message,则结束
|
||||
4. 如果未来返回 tool_call,则执行工具并继续循环
|
||||
5. 超过最大轮次则停止
|
||||
|
||||
参数:
|
||||
user_input:
|
||||
用户输入文本。
|
||||
|
||||
返回:
|
||||
Agent 最终返回给用户的文本。
|
||||
"""
|
||||
self.add_message("user", user_input)
|
||||
|
||||
for turn in range(self.max_turns):
|
||||
@@ -72,7 +426,12 @@ class Agent:
|
||||
|
||||
if step["type"] == "message":
|
||||
content = step["content"]
|
||||
self.add_message("assistant", content, turn=turn)
|
||||
self.add_message(
|
||||
"assistant",
|
||||
content,
|
||||
turn=turn,
|
||||
chunks=step.get("chunks", []),
|
||||
)
|
||||
return content
|
||||
|
||||
if step["type"] == "tool_call":
|
||||
@@ -81,7 +440,12 @@ class Agent:
|
||||
|
||||
if not self.can_use_tool(tool_name):
|
||||
error = f"Tool not allowed or not found: {tool_name}"
|
||||
self.add_message("tool_result", error, ok=False, tool=tool_name)
|
||||
self.add_message(
|
||||
"tool_result",
|
||||
error,
|
||||
ok=False,
|
||||
tool=tool_name,
|
||||
)
|
||||
continue
|
||||
|
||||
result = self.tools[tool_name].call(tool_input)
|
||||
@@ -103,5 +467,19 @@ class Agent:
|
||||
|
||||
|
||||
def echo_tool(payload: dict[str, Any]) -> ToolResult:
|
||||
"""
|
||||
一个最简单的教学示例工具。
|
||||
|
||||
作用:
|
||||
把输入文本原样回显回来。
|
||||
|
||||
参数:
|
||||
payload:
|
||||
工具输入字典。
|
||||
约定使用 `text` 字段。
|
||||
|
||||
返回:
|
||||
ToolResult:工具执行结果。
|
||||
"""
|
||||
text = str(payload.get("text", ""))
|
||||
return ToolResult(ok=True, content=f"echo: {text}")
|
||||
|
||||
Reference in New Issue
Block a user