02|使用 uv 搭建 DeepAgents 股票分析项目
02|使用 uv 搭建 DeepAgents 股票分析项目
上一篇介绍了股票分析助手的整体设计。本篇开始搭建工程基础:用 uv 管理 Python、虚拟环境与依赖,将源码、工具、提示词、报告、日志和测试放到明确的位置,并打通命令行与 API 的最小运行链路。
本篇示例使用 Python 3.12,开发命令以 Windows PowerShell 为主。完成后,项目可以在没有模型密钥的情况下生成一份演示报告,也可以启动 FastAPI 服务。DeepAgents 依赖会安装到工程中,真实 Agent、行情工具和模型调用留给后续章节实现。这样可以先排除环境和目录问题,再调试外部服务。
演示程序不查询行情,也不生成投资建议。报告中的股票代码只是输入回显,不代表该证券已经经过市场有效性校验。
一、理解 uv 在项目中负责什么
uv 将常见的 Python 工程操作放在同一个命令入口下:
| 工程对象 | 文件或目录 | 作用 |
|---|---|---|
| Python 选择 | .python-version |
为 uv 提供项目默认 Python 版本请求 |
| 项目声明 | pyproject.toml |
描述包、Python 范围、直接依赖和开发工具配置 |
| 依赖解析结果 | uv.lock |
记录解析后的依赖版本、来源及相关制品信息 |
| 本地环境 | .venv/ |
安装当前平台实际需要的依赖 |
| 本地配置 | .env |
保存不提交的环境设置和密钥 |
pyproject.toml 表达“允许什么依赖”,uv.lock 表达“这次具体解析成什么依赖”。仅固定顶层库并不能固定所有间接依赖;团队复现环境时,应同时使用项目声明和锁文件。
锁文件也不是完整操作系统快照。Python、系统库、CPU 架构、包索引及 uv 版本仍可能影响安装和运行,调用远端模型时还会受到模型行为变化影响。本篇建立的是可复现的工程依赖基础。
二、初始化一个独立的 Python 项目
下面的 stock-assistant 是读者创建的示例代码工程,与保存本文的博客目录是两个概念。
1. 安装并确认 uv
Windows 可以使用:
winget install --id astral-sh.uv --exact
安装后重新打开终端,检查版本,并安装所需 Python:
uv --version
uv python install 3.12
uv init --package --python 3.12 stock-assistant
Set-Location stock-assistant
uv python pin 3.12
--package 创建可安装的项目,便于把命令行入口、API 和测试统一到同一个 Python 包中。发行包名称可以包含连字符,例如 stock-assistant;Python 导入名称使用下划线,例如 stock_assistant。
.python-version 中写 3.12 表示选择这个次版本系列,并不固定补丁版本。团队需要更严格的复现时,可在确认本机补丁版本后统一精确版本:
uv run python --version
# 将团队选定的完整版本写入 .python-version,例如 3.12.x 的实际版本号。
# uv python pin <完整版本号>
2. 写入项目声明,再添加依赖
将初始化生成的 pyproject.toml 替换为以下内容。这里先给出可直接使用的基础文件,第三方依赖交给下一组 uv add 命令写入,避免在教程里手工编造版本组合。
[build-system]
requires = ["hatchling>=1.26,<2"]
build-backend = "hatchling.build"
[project]
name = "stock-assistant"
version = "0.1.0"
description = "A reproducible foundation for a DeepAgents stock assistant"
requires-python = ">=3.12,<3.13"
dependencies = []
[project.scripts]
stock-assistant = "stock_assistant.cli:main"
[dependency-groups]
dev = []
[tool.hatch.build.targets.wheel]
packages = ["src/stock_assistant"]
[tool.pytest.ini_options]
testpaths = ["tests"]
[tool.ruff]
target-version = "py312"
line-length = 100
[tool.ruff.lint]
select = ["E", "F", "I"]
接着执行:
uv add deepagents langchain-openai pydantic-settings fastapi "uvicorn[standard]"
uv add --dev pytest httpx ruff
uv sync --locked
各依赖的职责如下:
deepagents:后续构建具备规划、工具调用等能力的 Agent。langchain-openai:后续连接 OpenAI 或支持兼容协议的模型服务;具体兼容能力需要实测。pydantic-settings:加载、转换和验证配置。fastapi、uvicorn:提供 HTTP 接口并运行开发服务。pytest、httpx:执行测试,并支持 FastAPI 的测试客户端。ruff:统一静态检查与格式化。
uv add 会更新 pyproject.toml、解析锁文件并同步环境。首次执行时得到的是当时可解析的版本组合;团队共享的是执行后生成的 pyproject.toml 和 uv.lock,不能要求每个成员各自从这个空依赖模板开始解析。
如果依赖解析失败,应先检查 Python 要求、包索引可达性和冲突信息,不要删除锁文件后盲目重试。本文不依赖具体 DeepAgents API 签名,后续接入 Agent 时,应以项目锁定版本的文档为准。
三、规划目录:代码、资源和运行产物分开
最终结构如下。空目录中的 .gitkeep 仅用于让版本控制保留目录结构。
stock-assistant/
├── .python-version
├── .env.example
├── .env # 本地配置,不提交
├── .gitignore
├── pyproject.toml
├── uv.lock
├── .venv/ # uv 生成,不提交
├── config/
│ └── README.md # 配置约定,不放密钥副本
├── src/
│ └── stock_assistant/
│ ├── __init__.py
│ ├── settings.py
│ ├── logging_config.py
│ ├── service.py
│ ├── cli.py
│ ├── api.py
│ ├── agents/
│ │ └── __init__.py # 后续放 Agent 构建逻辑
│ ├── tools/
│ │ ├── __init__.py
│ │ └── symbols.py
│ └── prompts/
│ └── analyst.md
├── reports/
│ └── .gitkeep
├── logs/
│ └── .gitkeep
└── tests/
├── test_settings.py
└── test_service.py
采用 src 布局后,包需要正确安装才能导入,有助于避免“在仓库根目录偶然能运行,换个位置就失败”的问题。提示词放在包内部,可以随发行包一起交付;报告和日志属于运行产物,放在源码之外。
在项目根目录创建所需路径和空文件:
$utf8 = [System.Text.UTF8Encoding]::new($false)
$dirs = @(
'config', 'reports', 'logs', 'tests',
'src/stock_assistant/agents',
'src/stock_assistant/tools',
'src/stock_assistant/prompts'
)
foreach ($dir in $dirs) {
New-Item -ItemType Directory -Force -Path $dir | Out-Null
}
$emptyFiles = @(
'src/stock_assistant/__init__.py',
'src/stock_assistant/agents/__init__.py',
'src/stock_assistant/tools/__init__.py',
'reports/.gitkeep', 'logs/.gitkeep'
)
foreach ($file in $emptyFiles) {
if (-not (Test-Path -LiteralPath $file)) {
[System.IO.File]::WriteAllText(
(Join-Path (Get-Location).Path $file), '', $utf8
)
}
}
下文列出的文件都使用 UTF-8 保存。PowerShell 5.1 的重定向默认编码容易造成混淆,因此写文本时可以沿用上面的 WriteAllText 与 $utf8,或者在编辑器中明确选择 UTF-8。
四、配置与密钥:只保留一个加载入口
1. 配置模板和忽略规则
创建 .env.example:
STOCK_APP_ENV=development
STOCK_REPORTS_DIR=reports
STOCK_LOGS_DIR=logs
STOCK_LOG_LEVEL=INFO
# 以下字段供后续真实模型调用使用;本篇演示不需要密钥。
OPENAI_API_KEY=
OPENAI_MODEL=
复制为本地配置,显式使用 UTF-8 读写:
$root = (Get-Location).Path
if (-not (Test-Path -LiteralPath '.env')) {
$template = [System.IO.File]::ReadAllText(
(Join-Path $root '.env.example'), [System.Text.Encoding]::UTF8
)
[System.IO.File]::WriteAllText((Join-Path $root '.env'), $template, $utf8)
}
将以下内容合并进 .gitignore,保留项目已有的其他规则:
.venv/
__pycache__/
*.py[cod]
.pytest_cache/
.ruff_cache/
dist/
build/
*.egg-info/
.env
.env.*
!.env.example
reports/*
!reports/.gitkeep
logs/*
!logs/.gitkeep
不要忽略 uv.lock。.gitignore 只影响尚未跟踪的文件,如果密钥已被提交,补上一条忽略规则不能撤回泄露,应立即撤销或轮换密钥,并按团队流程处理历史记录。
2. 实现类型化配置
创建 src/stock_assistant/settings.py:
from pathlib import Path
from typing import Literal
from pydantic import Field, SecretStr
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
env_prefix="STOCK_",
extra="ignore",
)
app_env: Literal["development", "test", "production"] = "development"
reports_dir: Path = Path("reports")
logs_dir: Path = Path("logs")
log_level: Literal["DEBUG", "INFO", "WARNING", "ERROR"] = "INFO"
openai_api_key: SecretStr | None = Field(
default=None, validation_alias="OPENAI_API_KEY"
)
openai_model: str | None = Field(default=None, validation_alias="OPENAI_MODEL")
def prepare_directories(self) -> None:
self.reports_dir.mkdir(parents=True, exist_ok=True)
self.logs_dir.mkdir(parents=True, exist_ok=True)
def require_model_credentials(self) -> tuple[str, str]:
key = self.openai_api_key.get_secret_value() if self.openai_api_key else ""
model = (self.openai_model or "").strip()
if not key.strip() or not model:
raise ValueError("真实模型调用需要 OPENAI_API_KEY 和 OPENAI_MODEL")
return key, model
普通字段使用 STOCK_ 前缀;模型字段通过 validation_alias 接收显式指定的变量名,不再叠加前缀。进程环境变量优先于 .env,显式传入 Settings(...) 的参数通常又优先于这些配置来源,便于测试注入临时目录。
这里由 pydantic-settings 读取 .env,无须再用其他库重复加载。.env 和相对目录均以启动时的工作目录为基准,所以本文的所有运行命令都要求在项目根目录执行。部署到服务环境时,建议使用绝对报告路径和日志路径。
SecretStr 会在常见展示场景中隐藏值,但它不等于加密存储。不要记录完整设置对象、请求头或 get_secret_value() 的结果。生产环境使用平台注入的环境变量或密钥管理服务,不将 .env 打包进镜像。
config/README.md 可以保存以下约定:
# 配置约定
- Settings 是应用配置的统一入口。
- 本地配置通过根目录 .env 提供,模板为 .env.example。
- 环境变量覆盖 .env;部署时通过运行平台注入密钥。
- 从项目根目录启动;部署时为报告和日志使用绝对路径。
- config 目录只保存配置说明及后续不含密钥的配置文件。
五、实现可运行的演示链路
1. 输入工具与提示词
创建 src/stock_assistant/tools/symbols.py:
import re
def normalize_symbol(value: str) -> str:
symbol = value.strip().upper()
if not re.fullmatch(r"[A-Z0-9][A-Z0-9.-]{0,19}", symbol):
raise ValueError("股票代码需为 1 至 20 位字母、数字、点或连字符")
return symbol
它允许 AAPL、BRK.B、600519.SH 等形式,并排除路径分隔符、换行符等字符。这只是格式约束;市场、交易所、退市状态和代码映射应由后续行情工具负责。
创建 src/stock_assistant/prompts/analyst.md:
你是股票研究助手。分析时遵守以下要求:
1. 区分可核验事实、推断与待验证假设。
2. 对价格、财务指标及新闻注明来源和时间。
3. 数据不足时明确说明缺口,不编造行情或财报。
4. 输出业务概览、证据、风险及后续核查事项。
5. 不承诺收益,不将研究结论包装成确定性买卖指令。
2. 日志初始化
创建 src/stock_assistant/logging_config.py:
import logging
from logging.handlers import RotatingFileHandler
from stock_assistant.settings import Settings
def configure_logging(settings: Settings) -> None:
settings.prepare_directories()
logger = logging.getLogger("stock_assistant")
logger.setLevel(settings.log_level)
logger.propagate = False
for handler in logger.handlers[:]:
logger.removeHandler(handler)
handler.close()
formatter = logging.Formatter(
"%(asctime)s %(levelname)s %(name)s %(message)s"
)
console = logging.StreamHandler()
file_handler = RotatingFileHandler(
settings.logs_dir / "app.log",
maxBytes=5_000_000,
backupCount=3,
encoding="utf-8",
)
for handler in (console, file_handler):
handler.setFormatter(formatter)
logger.addHandler(handler)
只配置本项目的 logger,避免重置 Uvicorn 等第三方库的日志。文件轮转用于本地单进程开发;部署多个进程或容器时,应改为输出到标准输出并交给平台采集,不让多个进程竞争同一个轮转文件。
3. 公共服务函数
创建 src/stock_assistant/service.py:
import logging
from datetime import datetime, timezone
from importlib.resources import files
from pathlib import Path
from uuid import uuid4
from stock_assistant.settings import Settings
from stock_assistant.tools.symbols import normalize_symbol
logger = logging.getLogger(__name__)
def create_demo_report(symbol: str, settings: Settings) -> Path:
normalized = normalize_symbol(symbol)
settings.prepare_directories()
prompt = (
files("stock_assistant")
.joinpath("prompts", "analyst.md")
.read_text(encoding="utf-8")
)
if not prompt.strip():
raise ValueError("分析提示词不能为空")
now = datetime.now(timezone.utc)
report_id = uuid4().hex
path = settings.reports_dir / f"{normalized}-{report_id}.md"
content = (
f"# {normalized} 工程联通报告\n\n"
f"- 生成时间:{now.isoformat()}\n"
"- 运行模式:演示,无模型调用\n"
"- 提示词资源:已成功读取\n\n"
"## 当前结论\n\n"
"项目已打通输入校验、配置加载、包内资源读取与 UTF-8 报告写入。\n"
"本报告未获取实时行情、财务数据或新闻,不构成投资建议。\n\n"
"## 待接入能力\n\n"
"- DeepAgents 分析流程\n"
"- 行情与财报工具\n"
"- 证据引用及分析质量检查\n"
)
path.write_text(content, encoding="utf-8")
logger.info("demo_report_created symbol=%s report_id=%s", normalized, report_id)
return path
用 importlib.resources 读取包内提示词,避免依赖 src/... 这样的源码相对路径。UUID 用于区分多次生成的报告,避免重复运行覆盖同一文件。函数返回路径,由 CLI 和 API 分别组织输出,两种入口不需要各写一套业务逻辑。
这里读取提示词只是验证资源能够正确交付,并没有将其发送给模型。真正调用模型前,再使用 require_model_credentials() 做必填校验。
4. 命令行入口
创建 src/stock_assistant/cli.py:
import argparse
from stock_assistant.logging_config import configure_logging
from stock_assistant.service import create_demo_report
from stock_assistant.settings import Settings
def main() -> None:
parser = argparse.ArgumentParser(description="股票分析项目演示入口")
parser.add_argument("symbol", help="例如 AAPL 或 600519.SH")
args = parser.parse_args()
settings = Settings()
configure_logging(settings)
try:
path = create_demo_report(args.symbol, settings)
except ValueError as exc:
parser.error(str(exc))
print(f"报告已生成:{path.resolve()}")
if __name__ == "__main__":
main()
同步项目并运行:
uv sync --locked
uv run --locked stock-assistant AAPL
uv run --locked stock-assistant 600519.SH
预期在 reports/ 下生成报告,在 logs/app.log 中记录执行信息。这里使用项目安装提供的 stock-assistant 命令,入口来自 pyproject.toml 的 [project.scripts]。
六、预留 API,建立前后端共同契约
创建 src/stock_assistant/api.py:
from contextlib import asynccontextmanager
from typing import Literal
from fastapi import FastAPI, HTTPException, Request
from pydantic import BaseModel, Field
from stock_assistant.logging_config import configure_logging
from stock_assistant.service import create_demo_report
from stock_assistant.settings import Settings
class AnalysisRequest(BaseModel):
symbol: str = Field(min_length=1, max_length=20)
class AnalysisResponse(BaseModel):
mode: Literal["demo"] = "demo"
report_file: str
@asynccontextmanager
async def lifespan(app: FastAPI):
settings = Settings()
configure_logging(settings)
app.state.settings = settings
yield
app = FastAPI(title="Stock Assistant", version="0.1.0", lifespan=lifespan)
@app.get("/health")
def health() -> dict[str, str]:
return {"status": "ok"}
@app.post("/api/analyses", response_model=AnalysisResponse)
def create_analysis(payload: AnalysisRequest, request: Request) -> AnalysisResponse:
try:
path = create_demo_report(payload.symbol, request.app.state.settings)
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
return AnalysisResponse(report_file=path.name)
启动本地服务:
uv run --locked uvicorn stock_assistant.api:app --reload --host 127.0.0.1 --port 8000
另开终端请求接口:
Invoke-RestMethod -Uri 'http://127.0.0.1:8000/health'
Invoke-RestMethod -Method Post `
-Uri 'http://127.0.0.1:8000/api/analyses' `
-ContentType 'application/json; charset=utf-8' `
-Body '{"symbol":"AAPL"}'
响应结构示例:
{
"mode": "demo",
"report_file": "AAPL-<本次生成的UUID>.md"
}
report_file 是生成文件的名称,尚不是下载链接。返回文件名可以避免把服务器绝对路径直接暴露给调用方。可以通过 http://127.0.0.1:8000/docs 查看接口文档。
健康检查只说明服务已启动,不代表模型和行情服务可用。当前同步接口适合快速的本地演示;后续真实股票分析耗时较长,应升级为提交任务、查询状态和获取报告的流程。
浏览器前端可以通过开发服务器将 /api 代理到 127.0.0.1:8000,保持请求同源。直接跨端口访问时,再按实际前端地址配置 CORS。本文接口没有鉴权,仅绑定本机用于开发,不作为公网部署配置。
七、添加能发现工程问题的测试
测试重点是配置覆盖、输入边界与报告产物,不访问网络,不消耗模型额度。
创建 tests/test_settings.py:
from stock_assistant.settings import Settings
def test_environment_overrides_dotenv(tmp_path, monkeypatch):
env_file = tmp_path / ".env"
env_file.write_text("STOCK_APP_ENV=development\n", encoding="utf-8")
monkeypatch.setenv("STOCK_APP_ENV", "test")
settings = Settings(_env_file=env_file)
assert settings.app_env == "test"
创建 tests/test_service.py:
import pytest
from stock_assistant.service import create_demo_report
from stock_assistant.settings import Settings
def test_demo_report_is_utf8_and_does_not_overwrite(tmp_path):
settings = Settings(
_env_file=None,
app_env="test",
log_level="INFO",
reports_dir=tmp_path / "reports",
logs_dir=tmp_path / "logs",
)
first = create_demo_report(" aapl ", settings)
second = create_demo_report("AAPL", settings)
assert first != second
assert first.parent == tmp_path / "reports"
assert first.name.startswith("AAPL-")
assert "不构成投资建议" in first.read_text(encoding="utf-8")
assert second.exists()
@pytest.mark.parametrize("symbol", ["../secret", "AAPL\nMSFT", "", "A" * 21])
def test_invalid_symbol_does_not_create_report(tmp_path, symbol):
settings = Settings(
_env_file=None,
app_env="test",
log_level="INFO",
reports_dir=tmp_path / "reports",
logs_dir=tmp_path / "logs",
)
with pytest.raises(ValueError):
create_demo_report(symbol, settings)
assert not settings.reports_dir.exists()
tmp_path 隔离报告产物,避免测试污染开发目录。_env_file=None 禁用本地 .env 文件读取,但不会关闭进程环境变量;关键字段通过构造参数覆盖,从而减少运行环境对测试的影响。
本地开发检查命令:
uv run --locked ruff check . --fix
uv run --locked ruff format .
uv run --locked pytest -q
格式化命令会修改源码,应检查其变更。持续集成中使用只检查、不自动修改的形式:
uv sync --locked
uv run --locked ruff check .
uv run --locked ruff format --check .
uv run --locked pytest -q
以上是供读者执行的验证步骤,本文没有把这些命令列为已在目标机器运行通过的结果。
八、锁文件和虚拟环境的日常用法
1. 无须手动激活虚拟环境
uv run 会在项目环境中运行命令,并按需检查或同步依赖:
uv run --locked python -c "import sys; print(sys.executable)"
uv tree
第一条输出应指向项目 .venv 内的 Python。编辑器也应选择该解释器:Windows 通常是 .venv\Scripts\python.exe,macOS/Linux 通常是 .venv/bin/python。
需要普通 python 命令时可以手动激活,但不要把激活步骤作为项目可运行的前提。添加项目依赖统一使用 uv add,避免仅安装进当前环境却遗漏项目声明和锁文件。
2. 区分 --locked 与 --frozen
| 参数 | 行为 | 推荐用途 |
|---|---|---|
--locked |
要求锁文件存在且与项目声明一致;需要修改锁文件时失败 | 团队开发检查、CI |
--frozen |
使用现有锁文件,不检查它是否与项目声明保持最新 | 已在前置步骤验证过锁文件的受控流程 |
不要把 --frozen 理解为更严格的配置一致性校验。修改依赖声明后,应重新解析并审查锁文件;CI 使用 --locked 可以及时发现遗漏。
3. 受控升级依赖
uv lock --upgrade-package deepagents
uv sync --locked
uv run --locked pytest -q
这会请求升级目标包,相关依赖可能因约束一起变化,并不保证只改一个版本号。升级后需要审查 uv.lock 差异,后续接入真实 Agent 后还应验证工具调用和模型连接。
uv lock --upgrade 会尝试升级整体依赖,适合有计划的维护,不适合作为每次启动前的操作。
4. 新环境的复现顺序
拿到包含锁文件的工程后,按下面的顺序准备环境:
- 安装团队约定的 uv,并根据
.python-version准备 Python。 - 使用 UTF-8 从
.env.example创建本地.env,按需设置密钥。 - 执行
uv sync --locked,安装锁定依赖。 - 执行静态检查与测试。
- 运行 CLI 演示,或启动 API 服务。
正常共享的文件包括源码、提示词、测试、配置说明、.env.example、.python-version、pyproject.toml 和 uv.lock。本机虚拟环境、密钥、日志和生成报告不应作为环境复现的输入。
九、排错时按边界逐层检查
| 现象 | 优先检查 |
|---|---|
无法导入 stock_assistant |
是否在项目根目录运行,是否执行 uv sync --locked,编辑器是否选择 .venv |
| 提示锁文件需要更新 | 项目声明是否变化;由依赖维护者更新并审查锁文件 |
.env 不生效 |
启动工作目录、字段前缀、是否被进程环境变量覆盖 |
| 中文报告乱码 | 编辑器保存编码、读取时编码;本文的文本读写均显式使用 UTF-8 |
| 提示词找不到 | prompts/analyst.md 是否位于 Python 包内,发布时资源是否被包含 |
| API 能启动但前端请求失败 | 请求地址、代理设置、端口及跨域配置 |
| 真实模型仍报认证错误 | 配置校验只证明值存在,还需核查提供方、权限和所选模型 |
至此,项目具备统一的依赖管理、配置加载、UTF-8 文本处理、日志输出、报告落盘和 CLI/API 入口。下一步可以在 agents/ 中实现 DeepAgents 构建逻辑,在 tools/ 中增加行情与财报工具,并通过公共服务层让命令行和前端复用同一条分析流程。