Martin Fowler 博客展示如何用 Pydantic-AI 从零构建自主 CLI Agent,包括工具集成和决策循环的实现细节。对 AI Agent 系统设计有深度学习价值。
CLI 编程智能体是一种与聊天机器人或自动补全工具截然不同的工具——它们是能够读取代码、运行测试并更新代码库的智能体。尽管商业工具令人印象深刻,但它们并不了解我们所处环境的特定上下文,以及具体项目中的各种独特之处。我们可以换一种方式:通过组装开源工具来构建自己的编程智能体,并将我们在测试、文档生成、代码推理和文件系统操作方面的特定开发规范融入其中。
Ben O’Mahony 是 Thoughtworks 的首席 AI 工程师。他是一位以结果为导向的 AI/工程领导者,在组建高绩效团队,以及大规模交付业务关键型 AI、机器学习与数据产品和平台方面拥有丰富经验。他对从研究到生产部署的完整工程与数据生命周期有着深厚的专业知识。Ben 擅长制定技术战略、推动执行,并与不同职能的团队合作,创造可衡量的影响。最近,Ben 一直高度专注于构建生成式 AI 平台、模型和智能体。
CLI 编程智能体浪潮
既然可以买,为什么还要自己构建?
我们的开发智能体架构
从简单开始:基础部分
第一项能力:测试!
增加智能:指令与意图
MCP 革命:可插拔能力
沙箱化 Python 执行
最新的库文档
搜索互联网以获取当前信息
结构化问题求解
针对推理进行优化
Desktop Commander:警告!能力越大,责任越大!
我们从 CLI 智能体中学到了什么
如果你尝试过 Claude Code、Gemini Code、Open Code 或 Simon Willison 的 LLM CLI,就会发现它们与 ChatGPT 或 Github Copilot 有着本质区别。它们不只是聊天机器人或自动补全工具,而是能够读取你的代码、运行测试、搜索文档,并以异步方式修改代码库的智能体。
但它们是如何工作的?对我来说,理解任何工具工作原理的最佳方式,都是亲自尝试构建一个。因此我们正是这么做的。在本文中,我将带你了解我们如何使用 Pydantic-AI 框架和模型上下文协议(MCP)构建自己的 CLI 编程智能体。你不仅会看到如何组装各个组件,还会了解为什么每项能力都很重要,以及它们如何改变你与代码协作的方式。
我们的实现使用了 AWS Bedrock,但借助 Pydantic-AI,你也可以轻松使用任何其他主流提供商,甚至使用完全在本地运行的 LLM。
在深入技术实现之前,我们先来看看为什么选择构建自己的解决方案。
使用自定义智能体后,答案很快就变得显而易见:尽管商业工具令人印象深刻,但它们是为通用场景构建的。我们的智能体则完全根据内部上下文,以及具体项目中各种细微而独特的情况进行了定制。更重要的是,构建它让我们深入了解了这些系统的工作原理,以及我们自己的生成式 AI 平台和开发工具体系所达到的质量水平。
可以把它想象成学习烹饪。你当然可以一直去餐厅吃饭,但理解风味如何组合、烹饪技巧如何发挥作用,会让你以不同的方式欣赏食物,也让你能够准确做出自己想要的东西。
从高层来看,我们的编程助手由几个关键组件构成:
核心 AI 模型:通过 AWS Bedrock 访问 Anthropic 的 Claude
Pydantic-AI 框架:提供智能体框架和许多实用工具,让我们的智能体从一开始就更加有用
MCP 服务器:为智能体提供专用工具的独立进程;MCP 是一种通用标准,用于定义承载这些工具的服务器
CLI 界面:用户与助手交互的方式
真正神奇的部分来自模型上下文协议(MCP),它允许 AI 模型通过标准化接口使用各种工具。这种架构让我们的助手具备很强的可扩展性——只需实现更多 MCP 服务器,就能轻松添加新的能力。不过,我们有点说得太超前了。
我们首先创建了一个基础项目结构,并安装必要的依赖项:
uv init
uv add pydantic_ai
uv add boto3
我们的主要依赖项包括:
pydantic-ai:用于构建 AI 智能体的框架
boto3:用于与 AWS API 交互
我们选择 Anthropic 的 Claude Sonnet 4(通过 AWS Bedrock 访问)作为基础模型,因为它在代码理解和生成方面表现出色。下面是我们在 main.py 中对它进行配置的方式:
import boto3
from pydantic_ai import Agent
from pydantic_ai.mcp import MCPServerStdio
from pydantic_ai.models.bedrock import BedrockConverseModel
from pydantic_ai.providers.bedrock import BedrockProvider
bedrock_config = BotocoreConfig(
read_timeout=300,
connect_timeout=60,
retries={"max_attempts": 3},
)
bedrock_client = boto3.client(
"bedrock-runtime", region_name="eu-central-1", config=bedrock_config
)
model = BedrockConverseModel(
"eu.anthropic.claude-sonnet-4-20250514-v1:0",
provider=BedrockProvider(bedrock_client=bedrock_client),
)
agent = Agent(
model=model,
)
if __name__ == "__main__":
agent.to_cli_sync()
到这个阶段,我们已经拥有了一个完全可用、带有聊天界面的 CLI。它的使用方式与 GUI 聊天界面类似,而只用这么少的代码就能实现这一点,确实很酷!不过,我们显然还可以继续改进。
既然每轮编程迭代后都要运行测试,为什么不让智能体替我们完成?听起来很简单,对吧?
import subprocess
@agent.tool_plain()
def run_unit_tests() -> str:
"""Run unit tests using uv."""
result = subprocess.run(
["uv", "run", "pytest", "-xvs", "tests/"], capture_output=True, text=True
)
return result.stdout
这里使用的 pytest 命令与你在终端中运行的相同(为了便于展示,我对我们实际使用的命令做了简化)。接着,神奇的事情发生了。我只需要说“X 无法正常工作”,智能体就会:
运行测试套件
确定具体哪些测试失败了
分析错误信息
提出有针对性的修复建议。
工作流的变化:我们不再需要盯着测试失败信息看,也不必把终端输出复制粘贴到 ChatGPT 中。现在,我们可以直接为智能体提供与代码库中问题高度相关的上下文。
不过,我们注意到,智能体有时会通过建议修改测试,而不是修改实际实现,来“修复”失败的测试。于是,我们进行了下一项改进。
我们意识到,需要让智能体更多地了解我们的开发理念,并引导它避开不良行为。
instructions = """
You are a specialised agent for maintaining and developing the XXXXXX codebase.
## Development Guidelines:
1. **Test Failures:**
- When tests fail, fix the implementation first, not the tests
- Tests represent expected behavior; implementation should conform to tests
- Only modify tests if they clearly don't match specifications
2. **Code Changes:**
- Make the smallest possible changes to fix issues
- Focus on fixing the specific problem rather than rewriting large portions
- Add unit tests for all new functionality before implementing it
3. **Best Practices:**
- Keep functions small with a single responsibility
- Implement proper error handling with appropriate exceptions
- Be mindful of configuration dependencies in tests
Remember to examine test failure messages carefully to understand the root cause before making any changes.
"""
agent = Agent(
instructions=instructions,
model=model,
)
工作流的变化:智能体现在理解了我们对测试驱动开发和最小化改动的重视。它不再在一个小修复足以解决问题时建议大规模重构(大多数时候如此)。
当然,我们可以继续从零开始构建所有功能,并花上好几天反复调整提示词,但我们希望快速推进,并利用其他人已经构建好的工具——模型上下文协议(MCP)由此登场。
正是在这里,我们的智能体从一个实用的助手,转变成了某种接近商业 CLI 智能体的工具。模型上下文协议(MCP)允许我们通过运行专用服务器来添加复杂能力。
MCP 是一种开放协议,对应用程序向 LLM 提供上下文的方式进行了标准化。可以把 MCP 想象成 AI 应用程序的 USB-C 接口。正如 USB-C 为设备连接各种外设和配件提供了标准化方式,MCP 也为 AI 模型连接不同的数据源和工具提供了标准化方式。
我们可以将这些服务器作为本地进程运行,因此不需要共享数据;同时通过 STDIN/STDOUT 与其交互,让一切保持简单且在本地完成。(有关工具和 MCP 的更多详细信息)
使用大型语言模型进行计算或执行它们创建的任意代码既不有效,也可能非常危险!为了使我们的 AI 智能体更准确和安全,首先添加的 MCP 是 Pydantic AI 的沙箱 Python 代码执行默认服务器:
run_python = MCPServerStdio(
"deno",
args=[
"run",
"-N",
"-R=node_modules",
"-W=node_modules",
"--node-modules-dir=auto",
"jsr:@pydantic/mcp-run-python",
"stdio",
],
)
agent = Agent(
...
mcp_servers=[
run_python
],
)
这为我们的 AI 智能体提供了一个沙箱,可以在其中测试想法、原型化解决方案并验证自己的建议。
注意:这与运行测试非常不同,运行测试需要本地环境。沙箱用于进行更加稳健的计算。因为编写代码输出数字然后执行该代码,比仅仅在计算中生成下一个令牌要可靠、可理解、可扩展和可重复得多。我们从前沿实验室(包括他们泄露的指令)看到这是一种更好的方法。
工作流改变:进行计算,即使是更复杂的计算,现在变得显著更可靠。这对许多事情都很有用,比如日期、求和、计数等。它还允许简单 Python 代码的快速迭代周期。
LLM 主要通过在历史数据上进行批量训练,这给了一个固定的数据截断日期,而编程语言和依赖包则继续变化和改进。所以我们添加了 Context7 以访问 LLM 可消费格式的最新 Python 库文档:
context7 = MCPServerStdio(
command="npx", args=["-y", "@upstash/context7-mcp"], tool_prefix="context"
)
工作流改变:在使用较新的库或尝试使用高级功能时,AI 智能体可以查找当前文档,而不是依赖可能过时的训练数据。这使得它对于实际开发工作更加可靠。
由于这个特定的 AI 智能体是为 AWS 平台构建的,我们添加了 AWS Labs MCP 服务器以获得全面的云文档和集成:
awslabs = MCPServerStdio(
command="uvx",
args=["awslabs.core-mcp-server@latest"],
env={"FASTMCP_LOG_LEVEL": "ERROR"},
tool_prefix="awslabs",
)
aws_docs = MCPServerStdio(
command="uvx",
args=["awslabs.aws-documentation-mcp-server@latest"],
env={"FASTMCP_LOG_LEVEL": "ERROR", "AWS_DOCUMENTATION_PARTITION": "aws"},
tool_prefix="aws_docs",
)
工作流改变:现在当我提到"Bedrock 超时"或"模型响应被截断"时,AI 智能体可以直接访问 AWS 文档来帮助排查配置问题。虽然我们只是触及了这两个服务器的表面,但这只是冰山一角——AWS Labs MCP 集合包括 CloudWatch 指标、Lambda 调试、IAM 策略分析等的服务器。即使仅有文档访问权限,云调试也变得更加对话性和上下文相关。
有时您需要任何文档中都找不到的信息——最近的 Stack Overflow 讨论、GitHub 问题或最新的最佳实践。我们添加了通用互联网搜索:
internet_search = MCPServerStdio(command="uvx", args=["duckduckgo-mcp-server"])
工作流改变:当遇到晦涩的错误或需要理解生态系统中的最近变化时,AI 智能体可以搜索当前的讨论和解决方案。这对于调试部署问题或理解依赖包中的破坏性更改特别有价值。
最有价值的补充之一是代码推理 MCP,它帮助 AI 智能体系统地思考复杂问题:
code_reasoning = MCPServerStdio(
command="npx",
args=["-y", "@mettamatt/code-reasoning"],
tool_prefix="code_reasoning",
)
工作流改变:AI 智能体不再仓促得出解决方案,而是将复杂问题分解为逻辑步骤,探索替代方法,并解释其推理过程。这对于架构决策和调试复杂问题来说是无价的。我可以问"为什么这个 API 调用间歇性失败?"并获得潜在原因的结构化分析,而不仅仅是猜测。
随着我们添加更复杂的功能,我们注意到推理和分析任务通常比常规文本生成花费更长的时间——特别是当输出在第一次尝试时没有正确格式化时。我们调整了 Bedrock 配置以更有耐心:
bedrock_config = BotocoreConfig(
read_timeout=300,
connect_timeout=60,
retries={"max_attempts": 3},
)
bedrock_client = boto3.client(
"bedrock-runtime", region_name="eu-central-1", config=bedrock_config
)
工作流改变:更长的超时意味着我们的 AI 智能体可以处理复杂的问题而不会超时。在分析大型代码库或推理复杂的架构决策时,AI 智能体可以花费必要的时间提供深思熟虑、推理充分的响应,而不是仓促地给出不完整的解决方案。
此时,我们的 AI 智能体已经相当有能力了——它可以推理问题、执行代码、搜索信息并访问 AWS 文档。这个 MCP 服务器将您的 AI 智能体从有帮助的助手转变为可以在您的开发环境中实际执行操作的东西:
desktop_commander = MCPServerStdio(
command="npx",
args=["-y", "@wonderwhy-er/desktop-commander"],
tool_prefix="desktop_commander",
)
Desktop Commander 提供了一个令人难以置信的全面工具包:文件系统操作(读、写、搜索)、带有进程管理的终端命令执行、使用 edit_block 的精确代码编辑,甚至交互式 REPL 会话。它建立在 MCP 文件系统服务器之上,但添加了关键功能,如搜索和替换编辑以及智能进程控制。
工作流改变:这是一切汇聚的地方。我现在可以说"身份验证测试失败,请修复此问题",AI 智能体会:
运行测试套件以查看具体失败情况
读取失败的测试文件以了解预期内容
检查身份验证模块代码
搜索代码库中的相关模式
查找相关库的文档
编辑修复实现
重新运行测试以验证修复
搜索可能需要更新的类似模式
所有这一切都发生在单个对话线程中,AI 智能体始终保持上下文。它不仅是生成代码建议——它像配对编程伙伴一样积极调试、编辑和验证修复。
安全模型也经过了深思熟虑,具有可配置的允许目录、被阻止的命令和适当的权限边界。您可以在 Desktop Commander 文档中了解其广泛的功能。
以下是我们的最终 AI 智能体配置:
import asyncio
import subprocess
import boto3
from pydantic_ai import Agent
from pydantic_ai.mcp import MCPServerStdio
from pydantic_ai.models.bedrock import BedrockConverseModel
from pydantic_ai.providers.bedrock import BedrockProvider
from botocore.config import Config as BotocoreConfig
bedrock_config = BotocoreConfig(
read_timeout=300,
connect_timeout=60,
retries={"max_attempts": 3},
)
bedrock_client = boto3.client(
"bedrock-runtime", region_name="eu-central-1", config=bedrock_config
)
model = BedrockConverseModel(
"eu.anthropic.claude-sonnet-4-20250514-v1:0",
provider=BedrockProvider(bedrock_client=bedrock_client),
)
agent = Agent(
model=model,
)
instructions = """
You are a specialised agent for maintaining and developing the XXXXXX codebase.
## Development Guidelines:
1. **Test Failures:**
- When tests fail, fix the implementation first, not the tests
- Tests represent expected behavior; implementation should conform to tests
- Only modify tests if they clearly don't match specifications
2. **Code Changes:**
- Make the smallest possible changes to fix issues
- Focus on fixing the specific problem rather than rewriting large portions
- Add unit tests for all new functionality before implementing it
3. **Best Practices:**
- Keep functions small with a single responsibility
- Implement proper error handling with appropriate exceptions
- Be mindful of configuration dependencies in tests
Remember to examine test failure messages carefully to understand the root cause before making any changes.
"""
run_python = MCPServerStdio(
"deno",
args=[
"run",
"-N",
"-R=node_modules",
"-W=node_modules",
"--node-modules-dir=auto",
"jsr:@pydantic/mcp-run-python",
"stdio",
],
)
internet_search = MCPServerStdio(command="uvx", args=["duckduckgo-mcp-server"])
code_reasoning = MCPServerStdio(
command="npx",
args=["-y", "@mettamatt/code-reasoning"],
tool_prefix="code_reasoning",
)
desktop_commander = MCPServerStdio(
command="npx",
args=["-y", "@wonderwhy-er/desktop-commander"],
tool_prefix="desktop_commander",
)
awslabs = MCPServerStdio(
command="uvx",
args=["awslabs.core-mcp-server@latest"],
env={"FASTMCP_LOG_LEVEL": "ERROR"},
tool_prefix="awslabs",
)
aws_docs = MCPServerStdio(
command="uvx",
args=["awslabs.aws-documentation-mcp-server@latest"],
env={"FASTMCP_LOG_LEVEL": "ERROR", "AWS_DOCUMENTATION_PARTITION": "aws"},
tool_prefix="aws_docs",
)
context7 = MCPServerStdio(
command="npx", args=["-y", "@upstash/context7-mcp"], tool_prefix="context"
)
agent = Agent(
instructions=instructions,
model=model,
mcp_servers=[
run_python,
internet_search,
code_reasoning,
context7,
awslabs,
aws_docs,
desktop_commander,
],
)
@agent.tool_plain()
def run_unit_tests() -> str:
"""Run unit tests using uv."""
result = subprocess.run(
["uv", "run", "pytest", "-xvs", "tests/"], capture_output=True, text=True
)
return result.stdout
async def main():
async with agent.run_mcp_servers():
await agent.to_cli()
if __name__ == "__main__":
asyncio.run(main())
调试变成协作式的:你拥有一个聪慧的合作伙伴,可以分析错误消息、提出假说,并帮助测试解决方案。
学习加速:当与陌生的库或模式打交道时,智能体可以解释现有代码、建议改进,并教你为什么某些方法效果更好。
减少上下文切换:与其在文档、Stack Overflow、AWS 控制台和 IDE 之间跳跃,你有一个单一界面可以访问所有这些资源,同时保持对你具体问题的上下文理解。
问题解决变得有结构:与其直接跳向解决方案,智能体可以将复杂问题分解为逻辑步骤、探索替代方案,并解释其推理过程。就像拥有一个真实的对话橡皮鸭!
代码审查改善:智能体可以审查你的变更、发现潜在问题,并在你提交前建议改进——就像有一个资深开发者在你肩膀后面看着。
构建自己的智能体揭示了关于这个新兴范式的几个洞察:
MCP 几乎是你所需的全部:魔力不在任何单一能力中,而在于它们如何协同工作。能运行测试、读取文件、搜索文档、执行代码、访问 AWS 服务并系统地推理问题的智能体,从仅能执行任何单一任务的智能体在质上有根本不同。
最新信息至关重要:拥有实时搜索和最新文档的访问权限使智能体对真实世界的开发工作更可靠,因为训练数据可能已过时。
结构化思维很重要:代码推理能力将智能体从聪慧的自动完成转变为思维伙伴,可以分解复杂问题并探索替代解决方案。
上下文为王:Claude Code 这样的商业智能体印象深刻,部分原因是它在所有这些不同工具之间保持上下文。你的智能体需要记住在执行文件更改时从测试运行中学到的东西。
专业化很重要:我们的智能体对我们特定代码库的效果更好,优于通用工具,因为它理解我们的模式、约定和工具偏好。如果在任何方面表现不足,我们可以做出所需的改变。
CLI 智能体范式仍在快速演进。我们正在探索的一些领域:
AWS 特定工具:AWS Labs MCP 服务器(https://awslabs.github.io/mcp/)为云原生开发提供了令人难以置信的深度——从 CloudWatch 指标到 Lambda 调试再到 IAM 策略分析。
工作流增强:教导智能体我们常见的开发工作流,使其可以端到端地处理常规任务。将智能体连接到我们的项目管理工具,以便它可以理解优先级并与团队流程协调。
基准测试:Terminal Bench 看起来是针对这个玩具智能体测试真正大型工具的好数据集和排行榜!
CLI 编程智能体代表从 AI 作为写作助手到 AI 作为开发伙伴的根本转变。与 Copilot 的自动完成或 ChatGPT 的问答不同,这些智能体可以:
理解你的整个项目上下文
跨多个工具执行任务
在复杂工作流中保持状态
从你的特定代码库和模式中学习
构建你自己的智能体——即使是简单版本——让你洞察这项技术的发展方向,以及当这类商业工具出现时如何充分利用它们。
软件开发的未来不仅仅是编写代码更快。它关乎拥有一个聪明的合作伙伴,充分理解你的目标、你的约束和你的代码库,足以帮助你思考问题并协作实现解决方案。
理解那个未来的最好办法是什么?自己构建它。