我一直在用自己写的 StockTracker 管理个人投资记录。它不是一个需要注册账号的 SaaS,数据存在本地 SQLite,行情从多个公开接口聚合,收益计算基于 FIFO,手续费按市场自动算。
后来我想加一个 AI 对话能力,让模型能基于我的真实持仓做分析。最简单的做法是把所有持仓塞进上下文,直接问模型。但当持仓数量增长之后,这个方案开始暴露出几个问题:
- 问”成都银行走势怎么样”时,其他十几只股票的持仓数据也被塞进了上下文
- 单次请求的 token 消耗很高,响应变慢
- 模型被不相关数据干扰,回答变得笼统
- 调用链不透明,出了问题很难定位是哪一步的数据有问题
所以我决定为它设计一套 Agent Runtime。这篇文章会拆解这套 Agent 的核心思路。
一个前提:按需取数,而非全量灌入
很多 AI 应用的做法是把所有相关数据一次性塞进 system prompt,然后让模型自己去找答案。这种方式对小数据量场景够用,但对投资分析来说有一个根本矛盾:用户的持仓组合是动态增长的。
如果一个用户持有 30 只股票,每只都有交易记录、持仓成本、行情数据和技术指标,全量注入的上下文会非常庞大,而且其中 29 只和当前问题无关。
StockTracker Agent 的核心设计原则就是:只拿它需要的数据。
整体链路
Agent 的运行链路是单轮的 plan -> execute -> compose -> respond,不搞自主循环,不搞多轮工具调用(V1 阶段)。链路是这样的:
flowchart LR A["用户问题"] --> B["Planner"] B --> C["Skill Registry"] C --> D["Executor"] D --> E["Context Composer"] E --> F["LLM 回复"]
每一层都有明确的职责边界,下面我们逐个拆。
整体架构关系如下:
flowchart TB User["用户"] --> App["Next.js App Router"] App --> Store["Zustand 状态层"] App --> Api["API Routes"] Api --> SQLite["SQLite 本地数据库"] Api --> Finance["收益与手续费计算"] Api --> DataSources["行情与新闻数据源"] App --> AgentUI["AI 对话界面"] AgentUI --> Agent["Agent Runtime"] Agent --> Planner["Planner 意图识别"] Agent --> Skills["Skill Registry"] Skills --> SQLite Skills --> DataSources Agent --> LLM["LLM 模型服务"]
Planner:规则优先,LLM 兜底
Planner 的任务是把一句自然语言转成结构化的执行计划(AgentPlan)。它需要回答三个问题:
- 用户想做什么(intent)
- 涉及哪些标的(entities)
- 需要读取哪些数据(requiredSkills)
这里有一个关键的设计决策:规则优先,LLM 兜底。
// 规则匹配:先用本地确定性规则const matches = matchStocks(content, stocks, 3)if (matches.length === 1 && matches[0].confidence >= 0.72) { // 高置信度命中,直接返回计划 return { intent: 'stock_analysis', entities: [...], requiredSkills: [...] }}// 规则无法判断 → LLM 兜底return planViaLLM(userMessage, stocks, aiConfig)
为什么这样做?因为投资场景里,股票名称和代码的识别不需要模型来做。“成都银行”命中了本地持仓名称,或者 601838 是一个明确的股票代码,这些用字符串匹配就够了,速度快、结果确定、不会幻觉。
LLM Planner 只在规则搞不定的时候才出场,比如用户说”平安”,本地有中国平安和平安银行两只,这时候需要模型帮忙理解上下文。LLM Planner 会输出一个严格的 JSON 计划:
{ "intent": "stock_analysis", "entities": [{ "type": "stock", "raw": "成都银行", "confidence": 0.92 }], "requiredSkills": [ { "name": "stock.match", "args": { "query": "成都银行" }, "reason": "匹配标的" }, { "name": "stock.getHolding", "args": { "stockId": "..." }, "reason": "读取持仓" } ], "responseMode": "answer"}
responseMode 有三种:answer(正常回答)、clarify(需要用户补充信息)、refuse(超出范围)。当标的不明确时,Agent 不会猜测,而是返回澄清问题让用户选择。
实体匹配:本地持仓的模糊匹配
StockTracker 有一套简单的实体匹配系统,用于从用户输入中识别出对应的持仓股票。匹配逻辑分几层:
// 代码精确匹配:601838if (code && stock.code.toUpperCase() === code) { confidence = 1}// 名称完全匹配:"成都银行"else if (normalizedQuery === normalizedName) { confidence = 0.98}// 用户输入包含完整持仓名称:"帮我看看成都银行"else if (normalizedQuery.includes(normalizedName)) { confidence = 0.92}// 持仓名称包含用户输入(模糊匹配):"成都" 匹配 "成都银行"else if (normalizedName.includes(normalizedQuery) && normalizedQuery.length >= 2) { confidence = 0.72}
当多个标的的置信度差距小于 0.2 时,Agent 不会猜测,而是返回候选项让用户澄清。比如用户说”平安”,系统会问:“你想分析的是中国平安、平安银行,还是其他标的?“
Skill 系统:受控的数据访问层
以一个具体的例子来看 Agent 的完整运行时序。当用户问”成都银行现在走势健康吗”时,内部的调用链路是这样的:
sequenceDiagram participant U as 用户 participant RT as AgentRuntime participant PL as Planner participant SK as Skills participant DB as SQLite / 行情源 participant LLM as LLM U->>RT: "成都银行现在走势健康吗" RT->>PL: plan(userMessage, stocks) PL-->>RT: AgentPlan(stock_analysis, 成都银行) RT->>SK: stock.getHolding(stockId) SK->>DB: 查询本地持仓 DB-->>SK: 持仓摘要 SK-->>RT: SkillResult RT->>SK: stock.getQuote(stockId) SK->>DB: 查询行情数据 DB-->>SK: 行情 / 估值 SK-->>RT: SkillResult RT->>SK: stock.getTechnicalSnapshot(stockId) SK-->>RT: 技术指标 RT->>RT: compose minimal context RT->>LLM: stream completion LLM-->>U: 流式回复
Skill 是 Agent 的数据访问接口。每个 Skill 有明确的输入、输出、权限范围和执行函数。
export const stockGetHoldingSkill: AgentSkill = { name: 'stock.getHolding', description: '读取单只股票的本地持仓、成本、盈亏和备注。', inputSchema: { stockId: 'string' }, requiredScopes: ['stock.read'], async execute(args, ctx) { const stock = findStock(ctx.stocks, args) if (!stock) return { skillName: 'stock.getHolding', ok: false, error: '未找到对应持仓' } // ... 读取持仓数据 },}
Skill 的设计有几个考虑:
权限白名单。每个 Skill 必须声明它需要的数据范围(requiredScopes),比如 stock.read、quote.read。默认拒绝所有未声明的能力,包括文件写入、shell 执行和无限制网络请求。
Markdown manifest。每个 Skill 在 skills/builtin/ 下有一个 SKILL.md,用 frontmatter 描述机器可读的元信息,正文描述使用场景和边界。这种方式让 Skill 的描述既能被代码解析,也能被人直接阅读。
---name: stock.getHoldingdescription: 读取单只股票的本地持仓、成本、盈亏和备注。version: 1scopes: - stock.read - quote.readscript: lib/agent/skills/stock.ts#stockGetHoldingSkill---# 使用场景当用户询问某只已持仓股票的走势、成本、盈亏、仓位、是否继续持有或风险时使用。
链式执行(V2)。Skill 可以返回 needsFollowUp: true 和 suggestedSkills,让 Executor 自动追加后续调用。比如先匹配标的,发现缺行情,再自动抓取行情。V1 阶段暂未启用。
当前内置的 Skill 覆盖了组合级、个股级、市场级和历史级四类数据:
| 类别 | Skill | 用途 |
|---|---|---|
| 组合 | portfolio.getSummary | 总成本、总盈亏、仓位概览 |
| 组合 | portfolio.getTopPositions | 最大仓位、最大盈亏 |
| 个股 | stock.getHolding | 持仓、成本、盈亏 |
| 个股 | stock.getQuote | 行情、PE、PB、市值 |
| 个股 | stock.getTechnicalSnapshot | K 线衍生技术指标 |
| 市场 | market.resolveCandidate | 歧义标的候选解析 |
| 市场 | web.search / web.fetch | 外部信息补充 |
Executor:执行计划并收集结果
Executor 接收 Planner 输出的 AgentPlan,逐个调用 requiredSkills 中声明的 Skill,收集执行结果。它有一个简单的去重机制(避免同一轮内重复调用相同的 Skill + 参数),以及最大轮次限制(MAX_ROUNDS = 10)防止无限循环。
export async function executeAgentPlan(plan, ctx): Promise<AgentSkillResult[]> { const results: AgentSkillResult[] = [] const seen = new Set<string>() const queue = [...plan.requiredSkills] let round = 0 while (queue.length > 0 && round < MAX_ROUNDS) { round++ const batch = queue.splice(0) for (const call of batch) { const key = callKey(call) if (seen.has(key)) continue seen.add(key) const result = await executeSingleCall(call, ctx) results.push(withToken(result)) } } return results}
每个 SkillResult 都会附带一个 tokenEstimate,用于后续的上下文预算管理。
Context Composer:组装最小必要上下文
这是控制上下文成本的核心。Context Composer 的任务是把 Skill 执行结果组装成模型能理解的最小上下文。
它的策略根据问题类型不同而不同:
单只股票问题(“成都银行走势健康吗”):
- 注入该股票的持仓摘要
- 注入该股票的最近交易
- 注入该股票的行情和技术指标
- 不注入其他任何股票的数据
组合风险问题(“仓位风险大吗”):
- 注入组合总览
- 注入 Top N 仓位
- 注入最大浮盈、最大浮亏
- 不注入每只股票的完整交易记录
在发送给模型之前,Context Composer 还会做一件事:生成 answerDraft。这是系统从 Skill 结果中自动抽取的回答骨架,包含事实(facts)、计算(calculations)、推断(inferences)、缺失数据(missingData)和质量警告(qualityWarnings)。
const contextSnapshot = { agent: { intent, responseMode, entities, requiredSkills }, skillResults: skillResults.map(compactSkillResult), answerDraft: compactAnswerDraft(answerDraft),}
这个 answerDraft 会作为 LLM 的回答依据,确保模型不会编造数据。如果某个 Skill 执行失败或返回空数据,missingData 会被标记,模型需要在回答中明确指出数据不足。
上下文组装完成后,还有一个安全阈值检查:如果总 token 数超过 maxContextTokens,优先裁剪历史消息,再压缩 Skill 结果。
历史对话的延续性
Agent 还处理了一个容易被忽略的问题:多轮对话中的上下文延续。
当用户说”它的成本是多少”时,“它”指的是什么?Agent 通过 findRecentStockFocus 从历史对话的 contextSnapshot 中提取最近讨论的标的,然后用 shouldUseRecentStockFocus 判断当前问题是否适合延续这个上下文。
const recentStockFocus = findRecentStockFocus(history, stocks)if (recentStockFocus && shouldUseRecentStockFocus(content)) { return { intent: 'stock_analysis', entities: [{ type: 'stock', stockId: recentStockFocus.id, ... }], requiredSkills: buildStockSkillCalls(recentStockFocus, content), responseMode: 'answer', }}
判断逻辑是:如果用户问题包含”收益”、“成本”、“建议”这类后续追问关键词,但不包含”组合”、“全部”这类明确切换到组合视角的关键词,就延续之前的标的。
系统提示词的克制
最后说一下系统提示词的设计。StockTracker 的系统提示词非常克制:
你是 StockTracker Agent,一名面向个人投资者的股票与持仓分析助手。你只能回答与用户当前持仓、用户明确提到的股票、交易记录、行情、估值、技术指标、风险、仓位和资产配置有关的问题。你必须优先基于 Agent 提供的 skillResults 回答,不得编造未提供的数据。涉及收益、交易、成本、分红、手续费等数字时,必须说明口径。你不能承诺收益,不能声称确定涨跌,不能提供内幕消息。不要在每次回复中输出免责声明。
没有角色扮演,没有”你是一个有 20 年经验的投资顾问”之类的设定。因为当 Skill 系统已经提供了结构化的数据和 answerDraft 之后,模型需要做的就是基于事实组织语言,而不是表演专业。
一些取舍
回顾这套设计,有几个取舍值得记录:
不做自主循环。V1 只做单轮 plan -> execute -> answer,不搞多轮工具调用。这让链路可预测、可调试,代价是无法处理”先匹配、再发现缺数据、再补充抓取”这种链式场景。这留给了 V2。
不绑定模型。Agent 通过 OpenAI-compatible 接口对接模型服务,理论上可以切换不同的模型提供商。Planner 和 Responder 用的是同一个接口,只是 system prompt 不同。
Trace 全量记录。每次 Agent 运行都会记录 plan、skill calls、skill results 和 context stats,持久化到 ai_agent_runs 表。这在调试阶段帮了大忙——当模型给出一个奇怪的回答时,可以回溯看到 Planner 识别了什么意图、调用了哪些 Skill、组装了什么样的上下文。
本地优先。所有持仓和交易数据存在本地 SQLite,AI API Key 放在 .env.local。Agent 不会把数据上传到任何云端服务(除了模型 API 调用时发送的必要上下文)。
写在最后
这套 Agent 的设计核心不是”让 AI 更聪明”,而是”让 AI 只看它需要看的数据”。当数据量小的时候,全量灌入和按需取数的差别不大。但当持仓组合持续增长,按需取数的优势就会越来越明显:token 消耗更低、回答更聚焦、调用链更透明。
如果你也在做类似的本地优先 AI 应用,这套 Skill + Planner + Context Composer 的模式或许值得参考。完整代码在 byte92/finance_sys,欢迎提 Issue。