在动手写代码之前,先建立一个关键认知:AI Agent 不是聊天机器人。
普通的 Chatbot 只能接收文本、生成文本 —— 你问它一个问题,它给你一段回答,仅此而已。而 AI Agent 能够感知环境、自主推理、采取行动,形成一个持续运转的循环:
这个感知 → 推理 → 行动的循环,就是 Agent 的核心运作模式。与 Chatbot 的"一问一答"不同,Agent 会自主决定调用什么工具、以什么顺序执行、什么时候需要更多信息、什么时候任务完成。
AI Agent 有很多形态 —— 客服 Agent、数据分析 Agent、自动化运维 Agent。我们要构建的是 Coding Agent:一个以代码仓库为"环境"、以文件读写和命令执行为"工具"、以 LLM 为"大脑"的编程助手。
在这个课程中,我们将从零开始构建一个 AI Coding Agent —— 一个能理解你的代码库、执行文件操作、运行命令的智能编程助手,类似于 Claude Code、Cursor Agent 的核心功能。
本课程提供 TypeScript 和 Python 两种语言实现,你可以选择自己熟悉的语言跟随。LLM API 本质上是 HTTP + JSON 的交互,这两门语言都有天然优势:TypeScript 的类型系统帮你精确描述 API 结构,Python 的 dataclass 和 Protocol 同样清晰;两者的 async/await 语法让流式处理非常自然,且 Anthropic 和 OpenAI 都提供一等公民级别的官方 SDK。
整个项目大约 18 章,从最基础的 API 调用开始,逐步构建出一个功能完整的 Agent:
API 客户端、流式响应、多轮对话、Tool Use —— 这是 Agent 的通信基础。
文件读写、命令执行、代码搜索 —— Agent 操作代码库的"双手"。
核心循环、自主规划、错误处理 —— Agent 自主思考和行动的"大脑"。
REPL、Markdown 渲染、安全防护 —— 让 Agent 好用且安全的"界面"。
本章是第一步:搭建项目骨架,定义核心类型,并实现能同时对接 Anthropic(Claude)和 OpenAI 兼容模型(DeepSeek、Qwen 等)的 LLM 客户端。
{
"name": "ai-coding-agent",
"version": "0.1.0",
"type": "module",
"scripts": {
"build": "tsc",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"@anthropic-ai/sdk": "^0.39.0",
"openai": "^4.80.0"
},
"devDependencies": {
"typescript": "^5.7.0",
"vitest": "^3.0.0"
}
}
依赖非常精简:@anthropic-ai/sdk 和 openai 是两个官方 SDK,vitest 是测试框架。注意 "type": "module" 表示我们使用 ES Module。
{
"compilerOptions": {
"target": "ES2022",
"module": "Node16",
"moduleResolution": "Node16",
"outDir": "dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"declaration": true
},
"include": ["src"],
"exclude": ["node_modules", "dist", "tests"]
}
target: "ES2022" 让我们可以使用 Array.at()、顶层 await 等现代特性。module: "Node16" 配合 moduleResolution: "Node16" 是 Node.js 原生 ESM 支持的标准配置,import 路径必须带 .js 后缀(即使源码是 .ts)。strict: true 开启所有严格类型检查,这在与 LLM API 交互时尤其重要 —— 你会希望编译器帮你捕获类型不匹配的问题,而不是在运行时遇到奇怪的错误。
[project]
name = "ai-coding-agent"
version = "0.1.0"
requires-python = ">=3.11"
dependencies = [
"anthropic>=0.39.0",
"openai>=1.50.0",
]
[project.optional-dependencies]
dev = [
"pytest>=8.0",
"pytest-asyncio>=0.24",
]
依赖同样精简:anthropic 和 openai 两个官方 SDK,pytest + pytest-asyncio 做测试。推荐使用 Python 3.11+,可以使用 X | Y 联合类型语法。
pyproject.toml 作为项目配置文件,这是 Python 现代项目的标准做法。dataclass 和 Protocol 是 Python 的类型系统核心工具,前者定义数据结构,后者定义接口契约(类似 TypeScript 的 interface)。from __future__ import annotations 开启延迟注解求值,让类型注解在运行时不被立即求值,避免循环引用问题。
在写任何实现代码之前,先定义好类型。这些类型是整个 Agent 的"语言" —— 所有模块都通过它们通信。
// Core message type for LLM conversation
export interface Message {
role: "user" | "assistant";
content: string;
}
// Response from a chat completion
export interface ChatResponse {
text: string;
stopReason: "end_turn" | "max_tokens";
usage: { inputTokens: number; outputTokens: number };
}
// Options for chat completion
export interface ChatOptions {
system?: string;
maxTokens?: number;
}
// Unified interface for LLM providers
export interface LLMProvider {
chat(messages: Message[], options?: ChatOptions): Promise<ChatResponse>;
}
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Protocol
@dataclass
class Message:
"""Core message type for LLM conversation."""
role: str # "user" or "assistant"
content: str
@dataclass
class ChatResponse:
"""Response from a chat completion."""
text: str
stop_reason: str # "end_turn" or "max_tokens"
usage: dict = field(default_factory=dict)
@dataclass
class ChatOptions:
"""Options for chat completion."""
system: str | None = None
max_tokens: int | None = None
class LLMProvider(Protocol):
"""Unified interface for LLM providers."""
async def chat(
self, messages: list[Message], options: ChatOptions | None = None
) -> ChatResponse: ...
这段代码值得仔细看。让我们逐个拆解:
Message 的 content 字段是 string | ContentBlock[],这是一个关键设计。简单的文本对话用 string 就够了,但当涉及 Tool Use 时,一条消息可能同时包含文字和工具调用请求,必须用 ContentBlock[] 来表达。
type 字段作为标签,你可以用 switch(block.type) 来安全地处理不同类型,编译器会自动推断每个分支的类型。if (block.type === "tool_use") 之后,TypeScript 就知道 block 是 ToolUseBlock,可以安全访问 block.name 和 block.input。
LLMProvider 只有两个方法:chat 和 stream。这是我们对所有 LLM 服务商的抽象 —— 不管是 Claude、GPT、DeepSeek 还是 Qwen,都必须实现这个接口。chat 返回完整响应,stream 返回一个异步可迭代对象,逐块吐出文本。
| stopReason | 含义 | Agent 应该做什么 |
|---|---|---|
end_turn | LLM 正常结束回复 | 展示给用户 |
tool_use | LLM 想调用工具 | 执行工具,把结果送回 LLM |
max_tokens | 达到 token 上限被截断 | 可能需要继续请求 |
现在来实现第一个 Provider —— 对接 Anthropic 的 Claude 模型。
import Anthropic from "@anthropic-ai/sdk";
import type {
Message,
ChatResponse,
ChatOptions,
LLMProvider,
} from "./types.js";
export interface AnthropicConfig {
apiKey: string;
model?: string;
}
export class AnthropicProvider implements LLMProvider {
private client: Anthropic;
private model: string;
constructor(config: AnthropicConfig) {
this.client = new Anthropic({ apiKey: config.apiKey });
this.model = config.model ?? "claude-sonnet-4-20250514";
}
async chat(
messages: Message[],
options?: ChatOptions
): Promise<ChatResponse> {
const params: Record<string, unknown> = {
model: this.model,
max_tokens: options?.maxTokens ?? 4096,
messages: messages.map((m) => ({ role: m.role, content: m.content })),
};
if (options?.system) params.system = options.system;
const response = await this.client.messages.create(
params as Anthropic.MessageCreateParamsNonStreaming
);
// Extract text from response content blocks
const text = response.content
.filter((b) => b.type === "text")
.map((b) => (b as Anthropic.TextBlock).text)
.join("");
return {
text,
stopReason:
response.stop_reason === "end_turn" ? "end_turn" : "max_tokens",
usage: {
inputTokens: response.usage.input_tokens,
outputTokens: response.usage.output_tokens,
},
};
}
}
from __future__ import annotations
from dataclasses import dataclass
from anthropic import AsyncAnthropic
from .types import ChatOptions, ChatResponse, Message
@dataclass
class AnthropicConfig:
api_key: str
model: str = "claude-sonnet-4-20250514"
class AnthropicProvider:
"""LLM provider for Anthropic Claude models."""
def __init__(self, config: AnthropicConfig) -> None:
self._client = AsyncAnthropic(api_key=config.api_key)
self._model = config.model
async def chat(
self, messages: list[Message], options: ChatOptions | None = None
) -> ChatResponse:
options = options or ChatOptions()
params: dict = {
"model": self._model,
"max_tokens": options.max_tokens or 4096,
"messages": [
{"role": m.role, "content": m.content} for m in messages
],
}
if options.system:
params["system"] = options.system
response = await self._client.messages.create(**params)
# Extract text from response content blocks
text = "".join(
b.text for b in response.content if b.type == "text"
)
stop_reason = (
"end_turn" if response.stop_reason == "end_turn" else "max_tokens"
)
return ChatResponse(
text=text,
stop_reason=stop_reason,
usage={
"input_tokens": response.usage.input_tokens,
"output_tokens": response.usage.output_tokens,
},
)
几个关键点:
formatContent 函数:负责将我们的内部格式转换为 Anthropic API 期望的格式。注意 tool_result 中 toolUseId 要映射成 tool_use_id(下划线命名)。claude-sonnet-4-20250514,你可以通过 config 覆盖。input_schema(下划线),我们的 Tool 类型用 inputSchema(驼峰)。as const:在构造 ContentBlock 时使用 type: "text" as const,是为了让 TypeScript 将 type 的类型推断为字面量 "text" 而非宽泛的 string,否则无法满足联合类型的约束。
很多国产大模型(DeepSeek、Qwen、智谱等)都兼容 OpenAI 的 API 格式,只需改变 baseURL 和 model 即可。我们只需实现一个 OpenAICompatibleProvider,就能同时支持所有这些模型。
import OpenAI from "openai";
import type {
Message,
ChatResponse,
ChatOptions,
LLMProvider,
} from "./types.js";
export interface OpenAICompatibleConfig {
apiKey: string;
baseURL: string;
model: string;
}
export class OpenAICompatibleProvider implements LLMProvider {
private client: OpenAI;
private model: string;
constructor(config: OpenAICompatibleConfig) {
this.client = new OpenAI({
apiKey: config.apiKey,
baseURL: config.baseURL,
});
this.model = config.model;
}
// Format messages for OpenAI API, prepending system message if present
private formatMessages(
messages: Message[],
system?: string
): OpenAI.ChatCompletionMessageParam[] {
const formatted: OpenAI.ChatCompletionMessageParam[] = [];
if (system) {
formatted.push({ role: "system", content: system });
}
for (const m of messages) {
formatted.push({ role: m.role, content: m.content });
}
return formatted;
}
async chat(
messages: Message[],
options?: ChatOptions
): Promise<ChatResponse> {
const response = await this.client.chat.completions.create({
model: this.model,
max_tokens: options?.maxTokens ?? 4096,
messages: this.formatMessages(messages, options?.system),
});
const choice = response.choices[0];
return {
text: choice.message.content ?? "",
stopReason:
choice.finish_reason === "stop" ? "end_turn" : "max_tokens",
usage: {
inputTokens: response.usage?.prompt_tokens ?? 0,
outputTokens: response.usage?.completion_tokens ?? 0,
},
};
}
}
Python 版本的 OpenAI 兼容 Provider 结构与 TypeScript 版一致,使用 openai 官方 SDK。由于篇幅较长,这里展示核心的 chat 方法签名和思路:
from __future__ import annotations
from dataclasses import dataclass
from openai import AsyncOpenAI
from .types import ChatOptions, ChatResponse, Message
@dataclass
class OpenAICompatibleConfig:
api_key: str
base_url: str
model: str
class OpenAICompatibleProvider:
"""LLM provider for OpenAI-compatible APIs (DeepSeek, Qwen, etc.)."""
def __init__(self, config: OpenAICompatibleConfig) -> None:
self._client = AsyncOpenAI(
api_key=config.api_key, base_url=config.base_url
)
self._model = config.model
def _format_messages(
self, messages: list[Message], system: str | None = None
) -> list[dict]:
"""Format messages for OpenAI API, prepending system message if present."""
formatted: list[dict] = []
if system:
formatted.append({"role": "system", "content": system})
for m in messages:
formatted.append({"role": m.role, "content": m.content})
return formatted
async def chat(
self, messages: list[Message], options: ChatOptions | None = None
) -> ChatResponse:
options = options or ChatOptions()
response = await self._client.chat.completions.create(
model=self._model,
max_tokens=options.max_tokens or 4096,
messages=self._format_messages(messages, options.system),
)
choice = response.choices[0]
stop_reason = (
"end_turn" if choice.finish_reason == "stop" else "max_tokens"
)
return ChatResponse(
text=choice.message.content or "",
stop_reason=stop_reason,
usage={
"input_tokens": getattr(response.usage, "prompt_tokens", 0),
"output_tokens": getattr(
response.usage, "completion_tokens", 0
),
},
)
| 特性 | Anthropic | OpenAI |
|---|---|---|
| System Prompt | 独立的 system 字段 | 第一条 role: "system" 消息 |
| Tool 定义 | input_schema | parameters(嵌在 function 里) |
| Tool 调用结果 | tool_use_id(在 user 消息的 content 中) | tool_call_id(独立的 role: "tool" 消息) |
| 停止原因 | tool_use / end_turn | tool_calls / stop |
LLMProvider 接口正是为了屏蔽这些差异。上层代码只需要关心 ChatResponse 和 ContentBlock,不需要知道底层用的是哪家的 API。
有了两个 Provider,我们需要一个统一的入口来创建它们。使用工厂模式,调用者只需要传入配置,不需要 import 具体的 Provider 类。
import { AnthropicProvider, type AnthropicConfig } from "./anthropic.js";
import {
OpenAICompatibleProvider,
type OpenAICompatibleConfig,
} from "./openai-compatible.js";
import type { LLMProvider } from "./types.js";
export interface ProviderConfig {
provider: "anthropic" | "openai-compatible";
apiKey: string;
model?: string;
baseURL?: string;
}
// Create an LLM provider instance from config
export function createProvider(config: ProviderConfig): LLMProvider {
if (config.provider === "anthropic") {
return new AnthropicProvider({
apiKey: config.apiKey,
model: config.model,
} as AnthropicConfig);
}
if (config.provider !== "openai-compatible") {
throw new Error(`Unknown provider: ${config.provider}`);
}
// OpenAI-compatible provider requires baseURL and model
if (!config.baseURL) {
throw new Error("baseURL is required for openai-compatible provider");
}
if (!config.model) {
throw new Error("model is required for openai-compatible provider");
}
return new OpenAICompatibleProvider({
apiKey: config.apiKey,
baseURL: config.baseURL,
model: config.model,
} as OpenAICompatibleConfig);
}
from __future__ import annotations
from dataclasses import dataclass
from .anthropic_provider import AnthropicConfig, AnthropicProvider
from .openai_compatible import OpenAICompatibleConfig, OpenAICompatibleProvider
@dataclass
class ProviderConfig:
provider: str # "anthropic" or "openai-compatible"
api_key: str
model: str | None = None
base_url: str | None = None
def create_provider(config: ProviderConfig):
"""Create an LLM provider instance from config."""
if config.provider == "anthropic":
return AnthropicProvider(
AnthropicConfig(api_key=config.api_key, model=config.model or "claude-sonnet-4-20250514")
)
if config.provider == "openai-compatible":
if not config.base_url:
raise ValueError("base_url is required for openai-compatible provider")
if not config.model:
raise ValueError("model is required for openai-compatible provider")
return OpenAICompatibleProvider(
OpenAICompatibleConfig(
api_key=config.api_key,
base_url=config.base_url,
model=config.model,
)
)
raise ValueError(f"Unknown provider: {config.provider}")
使用示例:
// 使用 Claude
const claude = createProvider({
provider: "anthropic",
apiKey: process.env.ANTHROPIC_API_KEY!,
});
// 使用 DeepSeek
const deepseek = createProvider({
provider: "openai-compatible",
apiKey: process.env.DEEPSEEK_API_KEY!,
baseURL: "https://api.deepseek.com",
model: "deepseek-chat",
});
// 两者用法完全一样
const response = await claude.chat([{ role: "user", content: "Hello!" }]);
import os
from src.llm.factory import ProviderConfig, create_provider
# 使用 Claude
claude = create_provider(ProviderConfig(
provider="anthropic",
api_key=os.environ["ANTHROPIC_API_KEY"],
))
# 两者用法完全一样
response = await claude.chat([Message(role="user", content="Hello!")])
TypeScript 将所有类型和实现统一导出,方便外部使用:
export type {
Message,
ChatResponse,
ChatOptions,
LLMProvider,
} from "./types.js";
export { AnthropicProvider, type AnthropicConfig } from "./anthropic.js";
export {
OpenAICompatibleProvider,
type OpenAICompatibleConfig,
} from "./openai-compatible.js";
export { createProvider, type ProviderConfig } from "./factory.js";
Python 将所有类型和实现统一导出:
from .types import Message, ChatResponse, ChatOptions, LLMProvider
from .anthropic_provider import AnthropicProvider, AnthropicConfig
from .openai_compatible import OpenAICompatibleProvider, OpenAICompatibleConfig
from .factory import create_provider, ProviderConfig
__all__ = [
"Message",
"ChatResponse",
"ChatOptions",
"LLMProvider",
"AnthropicProvider",
"AnthropicConfig",
"OpenAICompatibleProvider",
"OpenAICompatibleConfig",
"create_provider",
"ProviderConfig",
]
我们通过 mock SDK 来编写单元测试,不需要真实的 API Key。TypeScript 使用 vitest + vi.mock,Python 使用 pytest + unittest.mock。
import { describe, it, expect, vi } from "vitest";
import { AnthropicProvider } from "../src/llm/anthropic.js";
import { OpenAICompatibleProvider } from "../src/llm/openai-compatible.js";
import { createProvider } from "../src/llm/factory.js";
import type { Message } from "../src/llm/types.js";
// ── AnthropicProvider ──
vi.mock("@anthropic-ai/sdk", () => {
return {
default: class {
messages = {
create: vi.fn().mockResolvedValue({
content: [{ type: "text", text: "Hello from Claude!" }],
stop_reason: "end_turn",
usage: { input_tokens: 10, output_tokens: 5 },
}),
};
},
};
});
vi.mock("openai", () => {
return {
default: class {
chat = {
completions: {
create: vi.fn().mockResolvedValue({
choices: [
{
message: { role: "assistant", content: "Hello from GPT!" },
finish_reason: "stop",
},
],
usage: { prompt_tokens: 10, completion_tokens: 5 },
}),
},
};
},
};
});
describe("AnthropicProvider", () => {
it("should send a message and return a ChatResponse", async () => {
const provider = new AnthropicProvider({ apiKey: "test-key" });
const messages: Message[] = [{ role: "user", content: "Hi" }];
const response = await provider.chat(messages);
expect(response.text).toBe("Hello from Claude!");
expect(response.stopReason).toBe("end_turn");
expect(response.usage.inputTokens).toBe(10);
expect(response.usage.outputTokens).toBe(5);
});
it("should pass system prompt and maxTokens", async () => {
const provider = new AnthropicProvider({
apiKey: "test-key",
model: "claude-haiku-4-5-20251001",
});
const messages: Message[] = [{ role: "user", content: "Hi" }];
await provider.chat(messages, {
system: "You are helpful.",
maxTokens: 1024,
});
const mockCreate = (provider as any).client.messages.create;
const callArgs = mockCreate.mock.calls[0][0];
expect(callArgs.system).toBe("You are helpful.");
expect(callArgs.max_tokens).toBe(1024);
expect(callArgs.model).toBe("claude-haiku-4-5-20251001");
});
});
// ── OpenAICompatibleProvider ──
describe("OpenAICompatibleProvider", () => {
it("should send a message and return a ChatResponse", async () => {
const provider = new OpenAICompatibleProvider({
apiKey: "test-key",
baseURL: "https://api.deepseek.com",
model: "deepseek-chat",
});
const messages: Message[] = [{ role: "user", content: "Hi" }];
const response = await provider.chat(messages);
expect(response.text).toBe("Hello from GPT!");
expect(response.stopReason).toBe("end_turn");
expect(response.usage.inputTokens).toBe(10);
expect(response.usage.outputTokens).toBe(5);
});
it("should prepend system message", async () => {
const provider = new OpenAICompatibleProvider({
apiKey: "test-key",
baseURL: "https://api.deepseek.com",
model: "deepseek-chat",
});
const messages: Message[] = [{ role: "user", content: "Hi" }];
await provider.chat(messages, { system: "Be helpful." });
const mockCreate = (provider as any).client.chat.completions.create;
const callArgs = mockCreate.mock.calls[0][0];
expect(callArgs.messages[0]).toEqual({
role: "system",
content: "Be helpful.",
});
});
});
// ── Factory ──
describe("createProvider", () => {
it("should create AnthropicProvider", () => {
const p = createProvider({ provider: "anthropic", apiKey: "key" });
expect(p).toBeInstanceOf(AnthropicProvider);
});
it("should create OpenAICompatibleProvider", () => {
const p = createProvider({
provider: "openai-compatible",
apiKey: "key",
baseURL: "https://api.example.com",
model: "model-1",
});
expect(p).toBeInstanceOf(OpenAICompatibleProvider);
});
it("should throw if baseURL missing for openai-compatible", () => {
expect(() =>
createProvider({
provider: "openai-compatible",
apiKey: "key",
model: "m",
})
).toThrow("baseURL");
});
it("should throw if model missing for openai-compatible", () => {
expect(() =>
createProvider({
provider: "openai-compatible",
apiKey: "key",
baseURL: "https://api.example.com",
})
).toThrow("model");
});
it("should throw for unknown provider", () => {
expect(() =>
createProvider({ provider: "unknown" as any, apiKey: "key" })
).toThrow();
});
});
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from src.llm.anthropic_provider import AnthropicConfig, AnthropicProvider
from src.llm.factory import ProviderConfig, create_provider
from src.llm.openai_compatible import OpenAICompatibleConfig, OpenAICompatibleProvider
from src.llm.types import ChatOptions, Message
# ── AnthropicProvider ──
@pytest.mark.asyncio
class TestAnthropicProvider:
async def test_send_message_and_return_response(self):
with patch("src.llm.anthropic_provider.AsyncAnthropic"):
provider = AnthropicProvider(AnthropicConfig(api_key="test-key"))
mock_block = MagicMock()
mock_block.type = "text"
mock_block.text = "Hello from Claude!"
mock_response = MagicMock()
mock_response.content = [mock_block]
mock_response.stop_reason = "end_turn"
mock_response.usage.input_tokens = 10
mock_response.usage.output_tokens = 5
provider._client.messages.create = AsyncMock(return_value=mock_response)
messages = [Message(role="user", content="Hi")]
response = await provider.chat(messages)
assert response.text == "Hello from Claude!"
assert response.stop_reason == "end_turn"
assert response.usage["input_tokens"] == 10
assert response.usage["output_tokens"] == 5
async def test_pass_system_prompt_and_max_tokens(self):
with patch("src.llm.anthropic_provider.AsyncAnthropic"):
provider = AnthropicProvider(
AnthropicConfig(api_key="test-key", model="claude-haiku-4-5-20251001")
)
mock_block = MagicMock()
mock_block.type = "text"
mock_block.text = "Hi"
mock_response = MagicMock()
mock_response.content = [mock_block]
mock_response.stop_reason = "end_turn"
mock_response.usage.input_tokens = 5
mock_response.usage.output_tokens = 1
provider._client.messages.create = AsyncMock(return_value=mock_response)
messages = [Message(role="user", content="Hi")]
await provider.chat(messages, ChatOptions(system="Be helpful.", max_tokens=1024))
call_kwargs = provider._client.messages.create.call_args[1]
assert call_kwargs["system"] == "Be helpful."
assert call_kwargs["max_tokens"] == 1024
assert call_kwargs["model"] == "claude-haiku-4-5-20251001"
# ── OpenAICompatibleProvider ──
@pytest.mark.asyncio
class TestOpenAICompatibleProvider:
async def test_send_message_and_return_response(self):
with patch("src.llm.openai_compatible.AsyncOpenAI"):
provider = OpenAICompatibleProvider(
OpenAICompatibleConfig(
api_key="test-key",
base_url="https://api.deepseek.com",
model="deepseek-chat",
)
)
mock_choice = MagicMock()
mock_choice.message.content = "Hello from DeepSeek!"
mock_choice.finish_reason = "stop"
mock_response = MagicMock()
mock_response.choices = [mock_choice]
mock_response.usage.prompt_tokens = 10
mock_response.usage.completion_tokens = 5
provider._client.chat.completions.create = AsyncMock(
return_value=mock_response
)
messages = [Message(role="user", content="Hi")]
response = await provider.chat(messages)
assert response.text == "Hello from DeepSeek!"
assert response.stop_reason == "end_turn"
assert response.usage["input_tokens"] == 10
assert response.usage["output_tokens"] == 5
async def test_prepend_system_message(self):
with patch("src.llm.openai_compatible.AsyncOpenAI"):
provider = OpenAICompatibleProvider(
OpenAICompatibleConfig(
api_key="test-key",
base_url="https://api.deepseek.com",
model="deepseek-chat",
)
)
mock_choice = MagicMock()
mock_choice.message.content = "Hi"
mock_choice.finish_reason = "stop"
mock_response = MagicMock()
mock_response.choices = [mock_choice]
mock_response.usage.prompt_tokens = 5
mock_response.usage.completion_tokens = 1
provider._client.chat.completions.create = AsyncMock(
return_value=mock_response
)
messages = [Message(role="user", content="Hi")]
await provider.chat(messages, ChatOptions(system="Be helpful."))
call_kwargs = provider._client.chat.completions.create.call_args[1]
assert call_kwargs["messages"][0] == {
"role": "system",
"content": "Be helpful.",
}
# ── Factory ──
class TestCreateProvider:
@patch("src.llm.anthropic_provider.AsyncAnthropic")
def test_create_anthropic_provider(self, _mock):
p = create_provider(ProviderConfig(provider="anthropic", api_key="key"))
assert isinstance(p, AnthropicProvider)
@patch("src.llm.openai_compatible.AsyncOpenAI")
def test_create_openai_compatible_provider(self, _mock):
p = create_provider(
ProviderConfig(
provider="openai-compatible",
api_key="key",
base_url="https://api.example.com",
model="model-1",
)
)
assert isinstance(p, OpenAICompatibleProvider)
def test_throw_if_base_url_missing(self):
with pytest.raises(ValueError, match="base_url"):
create_provider(
ProviderConfig(
provider="openai-compatible", api_key="key", model="m"
)
)
def test_throw_if_model_missing(self):
with pytest.raises(ValueError, match="model"):
create_provider(
ProviderConfig(
provider="openai-compatible",
api_key="key",
base_url="https://api.example.com",
)
)
def test_throw_for_unknown_provider(self):
with pytest.raises(ValueError, match="Unknown"):
create_provider(ProviderConfig(provider="unknown", api_key="key"))
vi.mock 替换了整个 npm 模块。我们用一个简单的类来模拟 Anthropic 和 OpenAI 的 SDK,返回预定义的响应。这样测试不依赖网络,运行速度极快,且可以精确控制返回值来测试各种边界情况。
# 安装依赖
$ cd typescript
$ npm install
# 运行测试
$ npm test
✓ tests/llm.test.ts (5 tests) 12ms
✓ tests/stream.test.ts (4 tests) 8ms
✓ tests/history.test.ts (9 tests) 5ms
✓ tests/tools.test.ts (9 tests) 10ms
Test Files 4 passed (4)
Tests 27 passed (27)
Start at 10:30:00
Duration 1.2s
# 创建虚拟环境并安装依赖
$ cd python
$ python3 -m venv .venv
$ source .venv/bin/activate
$ pip install anthropic openai pytest pytest-asyncio
# 运行测试
$ python -m pytest tests/ -v
tests/test_llm.py::TestAnthropicProvider::test_send_message_and_return_response PASSED
tests/test_llm.py::TestCreateProvider::test_create_anthropic_provider PASSED
tests/test_llm.py::TestCreateProvider::test_throw_for_unknown_provider PASSED
========== 3 passed in 0.12s ==========
在这一章中,我们完成了:
Message、ContentBlock、ChatResponse、LLMProvider —— 这些类型会贯穿整个课程。AnthropicProvider 直连 Claude,OpenAICompatibleProvider 支持 DeepSeek/Qwen 等所有兼容 OpenAI 格式的模型。createProvider 统一入口,上层代码无需关心具体实现。下一章我们将实现流式响应,让 Agent 能像 ChatGPT 那样逐字输出回复。