feat: Phase 5 — AI Research Agent(受控工具白名单 + LLM 编排 + API)

- agent/tools.py:Tool 元数据(JSON Schema)+ 白名单调用(异常转可读反馈,不中断对话)
- agent/tools_impl.py:6 个受控工具 search_stocks / get_market_data / test_factor / run_backtest / get_experiment / compare_experiments —— 全部只读经 Job/Experiment 链路,研究自动归档;无 shell/任意执行/写删数据能力
- agent/llm.py:LLMClient 抽象 + OpenAI 兼容客户端(LLM_API_KEY/LLM_BASE_URL/LLM_MODEL 走 .env,未配置给出引导提示)+ 研究纪律 system prompt(反过拟合/样本外/成本)
- agent/service.py:编排循环(tool/final JSON 决策 → 执行 → 回喂 → 结论),轮次上限兜底,未知工具拒绝
- /api/agent/chat;httpx 移至主依赖;Job 默认工厂抽取(api/agent/executor 复用)
- 测试 6 项(白名单无 shell、完整研究循环产出、未知工具拒绝、轮次兜底),全量 79 passed / ruff clean
This commit is contained in:
Simon
2026-09-06 17:22:05 +08:00
parent 0ea229d766
commit d9be75a98f
14 changed files with 722 additions and 23 deletions
+77
View File
@@ -0,0 +1,77 @@
"""LLM 客户端抽象(Phase 5)。
实现约定:真实 Key 来自 .env(LLM_API_KEY / LLM_BASE_URL / LLM_MODEL,见 config.yaml 引用)。
测试注入 FakeLLMClient 走完整编排链路,不触网。
"""
from __future__ import annotations
from typing import Protocol
import httpx
from app.agent.tools import tools_schema
from app.agent.tools_impl import build_tools
from app.core.config import get_settings
SYSTEM_TEMPLATE = """你是个人 A 股量化研究助手(Research Assistant),不是系统管理员。
你可以调用以下工具(每次只能输出一个动作):
{tools}
输出规则:只输出一行 JSON,两种形态之一:
1. 需要调用工具:{{"tool": "<工具名>", "args": {{...}}}}
2. 给出结论:{{"final": "结论文本"}}
研究纪律(必须遵守):
- 先提出假设 → 用工具做因子测试或回测 → 基于实验事实分析,再给结论
- 不得仅凭单次回测高收益就宣布策略有效;要主动说明样本外、过拟合、
look-ahead bias、交易成本、参数敏感性等风险(未做验证的项要明说「未验证」)
- 全程只读:不得要求删除/修改数据或执行任意命令(你也没有这类工具)
- 回答使用简体中文
"""
class LLMClient(Protocol):
def chat(self, messages: list[dict]) -> str: ...
class OpenAICompatibleClient:
"""OpenAI 兼容 Chat Completions(qwen/dashscope、deepseek、openai 等均适用)。"""
def __init__(self, *, base_url: str, api_key: str, model: str, timeout: float = 60.0) -> None:
self._base_url = base_url.rstrip("/")
self._api_key = api_key
self._model = model
self._timeout = timeout
def chat(self, messages: list[dict]) -> str:
with httpx.Client(timeout=self._timeout) as client:
resp = client.post(
f"{self._base_url}/chat/completions",
headers={"Authorization": f"Bearer {self._api_key}"},
json={
"model": self._model,
"messages": messages,
"temperature": 0.2,
},
)
resp.raise_for_status()
data = resp.json()
return data["choices"][0]["message"]["content"]
def build_llm_from_settings():
"""从配置构造 LLM;未配置 Key 时返回 None(调用方给出引导提示)。"""
settings = get_settings()
if not settings.llm_api_key:
return None
return OpenAICompatibleClient(
base_url=settings.llm_base_url or "https://dashscope.aliyuncs.com/compatible-mode/v1",
api_key=settings.llm_api_key,
model=settings.llm_model,
)
def system_prompt() -> str:
return SYSTEM_TEMPLATE.format(tools=tools_schema(build_tools()))