"""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()))