前置说明:这是一个我自己学习Agent Harness开发的一个笔记,上传到这里纯属方便,这个项目叫Odd, 是通过分多个Phase来完成一个能够支持Tools MCP Skill 子agent 任务清单和上下文压缩的Agent框架。我会分多个Phase让AI完成代码,并阅读代码学习
至于为什么用Ado当头图,那当然是因为
Ado统治世界仓库在:https://github.com/GC-SHIRO/Odd.git
模型调用层 = Agent 的"嘴巴和耳朵"。它负责把 Agent 想说的话发给 AI 模型,再把 AI 模型的回复传回来。
为什么要单独搞一层?
想象你要打电话,但手上有两部手机:一部是 iPhone,一部是 Android。你不可能每次打电话都换一套操作方式吧?
模型调用层做的就是这件事——不管后面接的是 OpenAI 的 GPT,还是 Anthropic 的 Claude,Agent 都只用同一套方式"打电话"。后面换模型?改一行配置就行,Agent 的核心逻辑完全不用动。
这叫抽象,是写代码很重要的思想。



核心零件
1. 数据类型(odd/types.py)
聊天本质上就是一条条消息来回传。里面定义了定义了这些基本概念:
Message(消息):一条对话记录,包含"谁说的"(角色)和"说了什么"(内容)。
SYSTEM:系统指令,告诉模型"你现在是个 Agent"USER:用户的问题ASSISTANT:模型的回答TOOL:工具执行后的结果
ToolCall(工具调用):模型说"我要用某个工具"时,会带上这个结构,包含工具名和参数。
ChatResponse(模型回复):模型返回的完整内容,可能包含文字 + 工具调用请求。
这几个Dataclass的具体内容
@dataclass 是 Python 的一个语法糖,让你不用写一大堆 __init__() 代码。
@dataclass
class ToolCall:
"""Represents a tool invocation requested by the model."""
id: str
name: str
arguments: Dict[str, Any]
@dataclass
class Message:
"""A single message in the conversation."""
role: MessageRole
content: str
tool_calls: Optional[List[ToolCall]] = None
tool_call_id: Optional[str] = None
name: Optional[str] = None # tool name for tool role
@dataclass
class ChatResponse:
"""Structured response from a model provider."""
content: str
tool_calls: List[ToolCall] = field(default_factory=list)
raw: Optional[Any] = None # provider-specific raw response平时你要写 msg = Message(role=MessageRole.USER, content="你好"),不用手动定义构造函数,Python 自动帮你生成。
Message:聊天历史里的每一行记录ToolCall:模型说"我要调用某某工具"时的结构化信息ChatResponse:模型一次性返回的所有内容(文字 + 可能的工具请求)ToolSpec(阶段 2 出现):工具的"身份证",告诉模型这个工具叫什么、干嘛的、需要什么参数
2. 配置管理(odd/config.py)
API Key、模型名称、接口地址这些敏感信息绝对不能写死在代码里。我们通过环境变量来读取:
如何用 .env 文件管理配置?
每次开终端都要敲 export OPENAI_API_KEY=... 很麻烦。可以在项目根目录建一个 .env 文件:
OPENAI_API_KEY=sk-...
ANTHROPIC_API_KEY=sk-ant-...
OPENAI_BASE_URL=https://...然后在代码开头加载它:
from dotenv import load_dotenv
load_dotenv() # 自动读取当前目录的 .env 文件这样 os.getenv("OPENAI_API_KEY") 就能读到值了,而且 .env 可以加入 .gitignore,不会泄露密钥。
3. 模型提供者(odd/models/)
ModelProvider(抽象基类)
规定了一个接口:所有模型都必须实现 chat(messages) -> ChatResponse。就像 USB 接口规范——不管里面是什么芯片,插上去都能用。
OpenAIProvider
封装了 OpenAI 的 Python SDK。主要做两件事:
把我们的
Message转成 OpenAI 要求的格式把 OpenAI 的返回结果转成我们的
ChatResponse
AnthropicProvider
Claude 的 API 和 OpenAI 差别挺大,主要体现在消息格式上:
发送时的区别:
OpenAI:系统消息也是一条普通消息,和其他消息一起塞进
messages数组Anthropic:系统消息单独放在
system字段里,messages数组里只能放用户和助手的对话
# Anthropic 的请求结构
{
"model": "claude-3-5-haiku-latest",
"max_tokens": 4096,
"system": "你是一个有帮助的助手", # ← 单独放
"messages": [
{"role": "user", "content": "你好"},
{"role": "assistant", "content": "你好!"}
]
}返回内容的区别:
OpenAI:回复在
choices[0].message.content里,工具调用在choices[0].message.tool_callsAnthropic:回复是一个
content数组,里面可能有多个 "block":
type: "text"→ 普通文字type: "tool_use"→ 模型想调用工具
# Anthropic 的返回结构
{
"content": [
{"type": "text", "text": "我来帮你查一下"},
{"type": "tool_use", "id": "tool_01", "name": "shell", "input": {"command": "ls"}}
]
}所以在 anthropic.py 里,_to_anthropic_msg() 这个转换器专门负责把我们的 Message 转成 Claude 能懂的格式,也把 Claude 的返回拆成 ChatResponse。
学到的要点
接口先行:先定义统一的抽象,再写具体实现,以后加新模型很容易。
配置外置:密钥、地址、模型名都放环境变量,代码里不留敏感信息。
适配器模式:每个模型 API 格式不同,但对外暴露一样的接口。