Dexter 深度解析:自主金融研究 AI Agent 的工程實現
0. 什麼是 Dexter?
Dexter 是一個自主金融研究代理,能夠理解用戶的金融問題,自動規劃研究步驟,使用即時市場數據執行分析,並生成數據驅動的答案。它的設計理念類似於 Claude Code,但專門針對金融研究領域。
核心能力:
- 智慧任務規劃:自動將複雜查詢分解為結構化研究步驟
- 自主執行:選擇並執行正確的工具來獲取金融數據
- 依賴感知並行:根據任務依賴關係並行執行
- 即時金融數據:存取損益表、資產負債表、現金流量表等
- 多模型支援:OpenAI、Anthropic、Google AI 三大模型提供商
整體定位:
flowchart TD
classDef pad fill:transparent,stroke:transparent,color:transparent;
subgraph UserInterface["終端介面 (Ink + React)"]
direction TB
ui_pad[" "]:::pad
Input["用戶輸入"]
Progress["進度顯示"]
Answer["答案串流"]
end
subgraph Agent["Agent 核心 (四階段)"]
direction TB
agent_pad[" "]:::pad
Understand["1. Understand<br/>理解意圖"]
Plan["2. Plan<br/>規劃任務"]
Execute["3. Execute<br/>執行工具"]
Generate["4. Answer<br/>生成答案"]
end
subgraph Tools["金融工具集"]
direction TB
tools_pad[" "]:::pad
Financials["財務報表工具"]
Prices["股價工具"]
Metrics["指標工具"]
Filings["SEC 文件工具"]
Search["網路搜尋"]
end
subgraph LLM["多模型支援"]
direction TB
llm_pad[" "]:::pad
OpenAI["OpenAI GPT-5"]
Anthropic["Anthropic Claude"]
Google["Google Gemini"]
end
UserInterface --> Agent
Agent --> Tools
Agent --> LLM
1. 專案結構與技術棧
Dexter 的程式碼約有 2,500 行,採用 TypeScript 開發:
dexter/
├── src/
│ ├── index.tsx # 應用入口
│ ├── cli.tsx # CLI 主組件 (~315 行)
│ ├── theme.ts # 主題設定
│ ├── agent/ # Agent 核心 (~800 行)
│ │ ├── orchestrator.ts # 協調器 (509 行)
│ │ ├── prompts.ts # 所有 Prompt 模板 (320 行)
│ │ ├── state.ts # 狀態類型定義 (174 行)
│ │ ├── schemas.ts # Zod Schema (84 行)
│ │ └── phases/ # 三個執行階段
│ │ ├── understand.ts # 理解階段 (62 行)
│ │ ├── plan.ts # 規劃階段 (65 行)
│ │ └── execute.ts # 執行階段 (51 行)
│ ├── tools/ # 工具集 (~600 行)
│ │ ├── index.ts # 工具導出
│ │ ├── types.ts # 工具結果類型
│ │ ├── finance/ # 金融工具
│ │ │ ├── api.ts # API 客戶端
│ │ │ ├── fundamentals.ts# 財務報表
│ │ │ ├── prices.ts # 股價數據
│ │ │ ├── metrics.ts # 財務指標
│ │ │ ├── filings.ts # SEC 文件
│ │ │ ├── news.ts # 新聞
│ │ │ ├── estimates.ts # 分析師預估
│ │ │ ├── segments.ts # 業務分部
│ │ │ └── crypto.ts # 加密貨幣
│ │ └── search/ # 搜尋工具
│ │ └── tavily.ts # Tavily 搜尋
│ ├── model/ # LLM 整合
│ │ └── llm.ts # 多模型支援 (147 行)
│ ├── utils/ # 工具函數
│ │ ├── context.ts # 上下文管理 (263 行)
│ │ ├── message-history.ts # 對話歷史 (190 行)
│ │ ├── config.ts # 設定管理
│ │ └── env.ts # 環境變數
│ ├── hooks/ # React Hooks
│ │ ├── useAgentExecution.ts # Agent 執行 (389 行)
│ │ ├── useQueryQueue.ts # 查詢佇列
│ │ └── useApiKey.ts # API Key 管理
│ └── components/ # UI 組件
│ └── (多個 Ink 組件)
└── package.json
技術棧:
| 類別 | 技術 | 用途 |
|---|---|---|
| 運行時 | Bun | 高效能 JavaScript 運行時 |
| UI 框架 | React + Ink | 終端 UI |
| LLM 整合 | LangChain.js | 多模型支援 |
| Schema | Zod | 類型驗證與結構化輸出 |
| 語言 | TypeScript | 類型安全 |
| 金融 API | Financial Datasets | 即時金融數據 |
| 搜尋 | Tavily | 網路搜尋 |
2. 四階段執行模型
Dexter 採用四階段執行模型,每個查詢都經過這四個階段:
flowchart TD
Start([用戶輸入]) --> Understand
subgraph Phase1["階段 1: Understand"]
Understand["提取意圖與實體<br/>• 識別股票代碼<br/>• 識別時間週期<br/>• 識別財務指標"]
end
subgraph Phase2["階段 2: Plan"]
Plan["建立任務清單<br/>• 設定 taskType<br/>• 設定依賴關係"]
end
subgraph Phase3["階段 3: Execute"]
Execute["執行任務<br/>• 並行執行無依賴任務<br/>• JIT 工具選擇<br/>• 儲存上下文"]
end
subgraph Phase4["階段 4: Answer"]
Answer["生成答案<br/>• 彙整任務結果<br/>• 串流輸出<br/>• 附帶來源"]
end
Phase1 --> Phase2 --> Phase3 --> Phase4
Answer --> End([答案輸出])
2.1 Agent 協調器核心邏輯
// src/agent/orchestrator.ts
export class Agent {
private readonly model: string;
private readonly contextManager: ToolContextManager;
private readonly tools: StructuredToolInterface[];
// 三個執行階段
private readonly understandPhase: UnderstandPhase;
private readonly planPhase: PlanPhase;
private readonly executePhase: ExecutePhase;
async run(query: string, messageHistory?: MessageHistory): Promise<string> {
const taskResults: Map<string, TaskResult> = new Map();
// ========== 階段 1: Understand ==========
this.callbacks.onPhaseStart?.('understand');
const understanding = await this.understandPhase.run({
query,
conversationHistory: messageHistory,
});
this.callbacks.onPhaseComplete?.('understand');
// ========== 階段 2: Plan ==========
this.callbacks.onPhaseStart?.('plan');
const plan = await this.planPhase.run({
query,
understanding,
});
this.callbacks.onPhaseComplete?.('plan');
// ========== 階段 3: Execute ==========
this.callbacks.onPhaseStart?.('execute');
await this.executeTasks(query, plan, understanding, taskResults);
this.callbacks.onPhaseComplete?.('execute');
// ========== 階段 4: Answer ==========
return this.generateFinalAnswer(query, plan, taskResults);
}
}
3. 完整 Prompt 模板
3.1 Understand 階段 Prompt
// src/agent/prompts.ts
export const UNDERSTAND_SYSTEM_PROMPT = `You are the understanding component for Dexter, a financial research agent.
Your job is to analyze the user's query and extract:
1. The user's intent - what they want to accomplish
2. Key entities - tickers, companies, dates, metrics, time periods
Current date: {current_date}
Guidelines:
- Be precise about what the user is asking for
- Identify ALL relevant entities (companies, tickers, dates, metrics)
- Normalize company names to ticker symbols when possible (e.g., "Apple" → "AAPL")
- Identify time periods (e.g., "last quarter", "2024", "past 5 years")
- Identify specific metrics mentioned (e.g., "P/E ratio", "revenue", "profit margin")
Return a JSON object with:
- intent: A clear statement of what the user wants
- entities: Array of extracted entities with type, value, and normalized form`;
輸出 Schema:
export const UnderstandingSchema = z.object({
intent: z.string()
.describe('A clear statement of what the user wants to accomplish'),
entities: z.array(z.object({
type: z.enum(['ticker', 'date', 'metric', 'company', 'period', 'other']),
value: z.string(),
})).describe('Key entities extracted from the query'),
});
3.2 Plan 階段 Prompt
export const PLAN_SYSTEM_PROMPT = `You are the planning component for Dexter, a financial research agent.
Create a MINIMAL task list to answer the user's query.
Current date: {current_date}
## Task Types
- use_tools: Task needs to fetch data using tools (e.g., get stock prices, financial metrics)
- reason: Task requires LLM to analyze, compare, synthesize, or explain data
## Rules
1. MAXIMUM 6 words per task description
2. Use 2-5 tasks total
3. Set taskType correctly:
- "use_tools" for data fetching tasks (e.g., "Get AAPL price data")
- "reason" for analysis tasks (e.g., "Compare valuations")
4. Set dependsOn to task IDs that must complete first
- Reasoning tasks usually depend on data-fetching tasks
## Examples
GOOD task list:
- task_1: "Get NVDA financial data" (use_tools, dependsOn: [])
- task_2: "Get peer company data" (use_tools, dependsOn: [])
- task_3: "Compare valuations" (reason, dependsOn: ["task_1", "task_2"])
Return JSON with:
- summary: One sentence (under 10 words)
- tasks: Array with id, description, taskType, dependsOn`;
輸出 Schema:
export const PlanSchema = z.object({
summary: z.string()
.describe('One sentence summary under 15 words'),
tasks: z.array(z.object({
id: z.string().describe('Unique identifier (e.g., "task_1")'),
description: z.string().describe('SHORT task description - must be under 10 words'),
taskType: z.enum(['use_tools', 'reason'])
.describe('use_tools = needs tools to fetch data, reason = LLM analysis only'),
dependsOn: z.array(z.string())
.describe('IDs of tasks that must complete before this one'),
})).describe('2-5 tasks with short descriptions'),
});
3.3 Tool Selection Prompt (JIT 工具選擇)
export const TOOL_SELECTION_SYSTEM_PROMPT = `Select and call tools to complete the task. Use the provided tickers and parameters.
{tools}`;
export function buildToolSelectionPrompt(
taskDescription: string,
tickers: string[],
periods: string[]
): string {
return `Task: ${taskDescription}
Tickers: ${tickers.join(', ') || 'none specified'}
Periods: ${periods.join(', ') || 'use defaults'}
Call the tools needed for this task.`;
}
關鍵設計:工具選擇使用小模型 gpt-5-mini 來降低成本:
const SMALL_MODEL = 'gpt-5-mini';
private async selectTools(task: Task, understanding: Understanding): Promise<ToolCallStatus[]> {
const tickers = understanding.entities
.filter(e => e.type === 'ticker')
.map(e => e.value);
const prompt = buildToolSelectionPrompt(task.description, tickers, periods);
// 使用小模型 + 綁定工具
const response = await callLlm(prompt, {
model: SMALL_MODEL,
systemPrompt,
tools: this.tools, // 傳入工具讓 LLM 選擇
});
return this.extractToolCalls(response);
}
3.4 Execute 階段 Prompt (用於 reason 任務)
export const EXECUTE_SYSTEM_PROMPT = `You are the reasoning component for Dexter, a financial research agent.
Your job is to complete an analysis task using the gathered data.
Current date: {current_date}
## Guidelines
- Focus only on what this specific task requires
- Use the actual data provided - cite specific numbers
- Be thorough but concise
- If comparing, highlight key differences and similarities
- If analyzing, provide clear insights
- If synthesizing, bring together findings into a conclusion
Your output will be used to build the final answer to the user's query.`;
3.5 Final Answer Prompt
export const FINAL_ANSWER_SYSTEM_PROMPT = `You are the answer generation component for Dexter, a financial research agent.
Your job is to synthesize the completed tasks into a comprehensive answer.
Current date: {current_date}
## Guidelines
1. DIRECTLY answer the user's question
2. Lead with the KEY FINDING in the first sentence
3. Include SPECIFIC NUMBERS with context
4. Use clear STRUCTURE - separate key data points
5. Provide brief ANALYSIS when relevant
## Format
- Use plain text ONLY - NO markdown (no **, *, _, #, etc.)
- Use line breaks and indentation for structure
- Present key numbers on separate lines
- Keep sentences clear and direct
## Sources Section (REQUIRED when data was used)
At the END, include a "Sources:" section listing data sources used.
Format: "number. (brief description): URL"
Example:
Sources:
1. (AAPL income statements): https://api.financialdatasets.ai/...
2. (AAPL price data): https://api.financialdatasets.ai/...
Only include sources whose data you actually referenced.`;
3.6 Context Selection Prompt (上下文篩選)
export const CONTEXT_SELECTION_SYSTEM_PROMPT = `You are a context selection agent for Dexter, a financial research agent.
Your job is to identify which tool outputs are relevant for answering a user's query.
You will be given:
1. The original user query
2. A list of available tool outputs with summaries
Your task:
- Analyze which tool outputs contain data directly relevant to answering the query
- Select only the outputs that are necessary - avoid selecting irrelevant data
- Consider the query's specific requirements (ticker symbols, time periods, metrics, etc.)
- Return a JSON object with a "context_ids" field containing a list of IDs (0-indexed) of relevant outputs
Example:
If the query asks about "Apple's revenue", select outputs from tools that retrieved Apple's financial data.
If the query asks about "Microsoft's stock price", select outputs from price-related tools for Microsoft.
Return format:
{{"context_ids": [0, 2, 5]}}`;
3.7 Message Summary Prompt (對話歷史摘要)
export const MESSAGE_SUMMARY_SYSTEM_PROMPT = `You are a summarization component for Dexter, a financial research agent.
Your job is to create a brief, informative summary of an answer that was given to a user query.
The summary should:
- Be 1-2 sentences maximum
- Capture the key information and data points from the answer
- Include specific entities mentioned (company names, ticker symbols, metrics)
- Be useful for determining if this answer is relevant to future queries
Example input:
{{
"query": "What are Apple's latest financials?",
"answer": "Apple reported Q4 2024 revenue of $94.9B, up 6% YoY..."
}}
Example output:
"Financial overview for Apple (AAPL) covering Q4 2024 revenue, earnings, and key metrics."`;
4. 依賴感知的任務並行執行
Dexter 的任務執行具備依賴感知的並行化能力:
flowchart TD
subgraph TaskGraph["任務依賴圖"]
T1["task_1: Get AAPL data<br/>(use_tools)"]
T2["task_2: Get MSFT data<br/>(use_tools)"]
T3["task_3: Get industry avg<br/>(use_tools)"]
T4["task_4: Compare metrics<br/>(reason)<br/>依賴: task_1, task_2, task_3"]
end
subgraph Wave1["第一波 (並行)"]
W1_T1["執行 task_1"]
W1_T2["執行 task_2"]
W1_T3["執行 task_3"]
end
subgraph Wave2["第二波"]
W2_T4["執行 task_4"]
end
T1 --> W1_T1
T2 --> W1_T2
T3 --> W1_T3
W1_T1 --> W2_T4
W1_T2 --> W2_T4
W1_T3 --> W2_T4
4.1 並行執行實現
// src/agent/orchestrator.ts
private async executeTasks(
query: string,
plan: Plan,
understanding: Understanding,
taskResults: Map<string, TaskResult>
): Promise<void> {
// 建立依賴圖
const nodes = new Map<string, TaskNode>();
for (const task of plan.tasks) {
nodes.set(task.id, { task, status: 'pending' });
}
// 持續執行直到所有任務完成
while (this.hasPendingTasks(nodes)) {
// 找出依賴已滿足的任務
const readyTasks = this.getReadyTasks(nodes);
if (readyTasks.length === 0) {
break; // 可能存在循環依賴
}
// 並行執行所有準備好的任務
await Promise.all(
readyTasks.map(task =>
this.executeTask(query, task, plan, understanding, taskResults, nodes)
)
);
}
}
private getReadyTasks(nodes: Map<string, TaskNode>): Task[] {
const ready: Task[] = [];
for (const node of nodes.values()) {
if (node.status !== 'pending') continue;
const deps = node.task.dependsOn || [];
const depsCompleted = deps.every(depId => {
const depNode = nodes.get(depId);
return depNode?.status === 'completed';
});
if (depsCompleted) {
node.status = 'ready';
ready.push(node.task);
}
}
return ready;
}
4.2 兩種任務類型的執行邏輯
private async executeTask(
query: string,
task: Task,
plan: Plan,
understanding: Understanding,
taskResults: Map<string, TaskResult>,
nodes: Map<string, TaskNode>
): Promise<void> {
node.status = 'running';
this.callbacks.onTaskUpdate?.(task.id, 'in_progress');
// ===== use_tools 類型:選擇並執行工具 =====
if (task.taskType === 'use_tools') {
// Step 1: 使用小模型選擇工具
const toolCalls = await this.selectTools(task, understanding);
task.toolCalls = toolCalls;
// Step 2: 並行執行選定的工具
if (toolCalls.length > 0) {
await this.executeTools(task, queryId);
}
node.status = 'completed';
return;
}
// ===== reason 類型:使用 LLM 分析數據 =====
if (task.taskType === 'reason') {
const contextData = this.buildContextData(query, taskResults, plan);
const result = await this.executePhase.run({
query,
task,
plan,
contextData,
});
taskResults.set(task.id, result);
node.status = 'completed';
}
}
5. 金融工具集
Dexter 提供 18 個金融數據工具:
flowchart LR
subgraph FinancialTools["金融工具集"]
direction TB
subgraph Fundamentals["基本面工具"]
F1["get_income_statements<br/>損益表"]
F2["get_balance_sheets<br/>資產負債表"]
F3["get_cash_flow_statements<br/>現金流量表"]
F4["get_all_financial_statements<br/>全部報表"]
end
subgraph Prices["價格工具"]
P1["get_price_snapshot<br/>即時股價"]
P2["get_prices<br/>歷史股價"]
P3["get_crypto_price_snapshot<br/>加密貨幣即時價"]
P4["get_crypto_prices<br/>加密貨幣歷史價"]
end
subgraph Metrics["指標工具"]
M1["get_financial_metrics_snapshot<br/>指標快照"]
M2["get_financial_metrics<br/>歷史指標"]
end
subgraph Filings["文件工具"]
FI1["get_filings<br/>SEC 文件列表"]
FI2["get_10K_filing_items<br/>10-K 年報"]
FI3["get_10Q_filing_items<br/>10-Q 季報"]
FI4["get_8K_filing_items<br/>8-K 即時報告"]
end
subgraph Others["其他工具"]
O1["get_news<br/>新聞"]
O2["get_analyst_estimates<br/>分析師預估"]
O3["get_segmented_revenues<br/>業務分部營收"]
O4["search_web<br/>Tavily 搜尋"]
end
end
5.1 工具實現範例:損益表
// src/tools/finance/fundamentals.ts
const FinancialStatementsInputSchema = z.object({
ticker: z.string()
.describe("The stock ticker symbol to fetch financial statements for. For example, 'AAPL' for Apple."),
period: z.enum(['annual', 'quarterly', 'ttm'])
.describe("The reporting period. 'annual' for yearly, 'quarterly' for quarterly, 'ttm' for trailing twelve months."),
limit: z.number().default(10)
.describe('Maximum number of report periods to return (default: 10).'),
report_period_gt: z.string().optional()
.describe('Filter for report periods after this date (YYYY-MM-DD).'),
report_period_gte: z.string().optional(),
report_period_lt: z.string().optional(),
report_period_lte: z.string().optional(),
});
export const getIncomeStatements = new DynamicStructuredTool({
name: 'get_income_statements',
description: `Fetches a company's income statements, detailing its revenues, expenses, net income, etc. over a reporting period. Useful for evaluating a company's profitability and operational efficiency.`,
schema: FinancialStatementsInputSchema,
func: async (input) => {
const params = createParams(input);
const { data, url } = await callApi('/financials/income-statements/', params);
return formatToolResult(data.income_statements || {}, [url]);
},
});
5.2 工具結果格式
所有工具都返回統一格式,包含來源 URL:
// src/tools/types.ts
export interface ToolResult {
data: unknown;
sourceUrls?: string[];
}
export function formatToolResult(data: unknown, sourceUrls?: string[]): string {
const result: ToolResult = { data };
if (sourceUrls?.length) {
result.sourceUrls = sourceUrls;
}
return JSON.stringify(result);
}
5.3 API 客戶端
// src/tools/finance/api.ts
const BASE_URL = 'https://api.financialdatasets.ai';
export async function callApi(
endpoint: string,
params: Record<string, string | number | string[] | undefined>
): Promise<ApiResponse> {
const FINANCIAL_DATASETS_API_KEY = process.env.FINANCIAL_DATASETS_API_KEY;
const url = new URL(`${BASE_URL}${endpoint}`);
// 添加查詢參數
for (const [key, value] of Object.entries(params)) {
if (value !== undefined && value !== null) {
if (Array.isArray(value)) {
value.forEach((v) => url.searchParams.append(key, v));
} else {
url.searchParams.append(key, String(value));
}
}
}
const response = await fetch(url.toString(), {
headers: {
'x-api-key': FINANCIAL_DATASETS_API_KEY || '',
},
});
if (!response.ok) {
throw new Error(`API request failed: ${response.status} ${response.statusText}`);
}
const data = await response.json();
return { data, url: url.toString() };
}
6. 多模型支援架構
Dexter 支援三大 LLM 提供商:
flowchart TD
subgraph ModelSelection["模型選擇"]
Input["模型名稱"]
Check{"前綴判斷"}
Input --> Check
Check -->|claude-*| Anthropic["ChatAnthropic"]
Check -->|gemini-*| Google["ChatGoogleGenerativeAI"]
Check -->|其他| OpenAI["ChatOpenAI (默認)"]
end
subgraph Features["功能支援"]
Streaming["串流輸出"]
StructuredOutput["結構化輸出"]
ToolBinding["工具綁定"]
end
Anthropic --> Features
Google --> Features
OpenAI --> Features
6.1 模型工廠實現
// src/model/llm.ts
export const DEFAULT_MODEL = 'gpt-5.2';
const MODEL_PROVIDERS: Record<string, ModelFactory> = {
'claude-': (name, opts) =>
new ChatAnthropic({
model: name,
...opts,
apiKey: getApiKey('ANTHROPIC_API_KEY', 'Anthropic'),
}),
'gemini-': (name, opts) =>
new ChatGoogleGenerativeAI({
model: name,
...opts,
apiKey: getApiKey('GOOGLE_API_KEY', 'Google'),
}),
};
const DEFAULT_PROVIDER: ModelFactory = (name, opts) =>
new ChatOpenAI({
model: name,
...opts,
apiKey: process.env.OPENAI_API_KEY,
});
export function getChatModel(
modelName: string = DEFAULT_MODEL,
streaming: boolean = false
): BaseChatModel {
const opts: ModelOpts = { streaming };
const prefix = Object.keys(MODEL_PROVIDERS).find((p) => modelName.startsWith(p));
const factory = prefix ? MODEL_PROVIDERS[prefix] : DEFAULT_PROVIDER;
return factory(modelName, opts);
}
6.2 統一的 LLM 調用介面
interface CallLlmOptions {
model?: string;
systemPrompt?: string;
outputSchema?: z.ZodType<unknown>; // 結構化輸出
tools?: StructuredToolInterface[]; // 工具綁定
}
export async function callLlm(prompt: string, options: CallLlmOptions = {}): Promise<unknown> {
const { model = DEFAULT_MODEL, systemPrompt, outputSchema, tools } = options;
const promptTemplate = ChatPromptTemplate.fromMessages([
['system', finalSystemPrompt],
['user', '{prompt}'],
]);
const llm = getChatModel(model, false);
let runnable: Runnable<any, any> = llm;
// 結構化輸出
if (outputSchema) {
runnable = llm.withStructuredOutput(outputSchema);
}
// 工具綁定
else if (tools && tools.length > 0 && llm.bindTools) {
runnable = llm.bindTools(tools);
}
const chain = promptTemplate.pipe(runnable);
const result = await withRetry(() => chain.invoke({ prompt }));
return result;
}
6.3 串流輸出支援
export async function* callLlmStream(
prompt: string,
options: { model?: string; systemPrompt?: string } = {}
): AsyncGenerator<string> {
const llm = getChatModel(model, true); // streaming = true
const chain = promptTemplate.pipe(llm);
for (let attempt = 0; attempt < 3; attempt++) {
try {
const stream = await chain.stream({ prompt });
for await (const chunk of stream) {
if (chunk && typeof chunk === 'object' && 'content' in chunk) {
const content = chunk.content;
if (content && typeof content === 'string') {
yield content;
}
}
}
return;
} catch (e) {
if (attempt === 2) throw e;
await new Promise((r) => setTimeout(r, 500 * 2 ** attempt));
}
}
}
7. 上下文管理系統
Dexter 使用檔案系統來持久化工具調用結果:
flowchart TD
subgraph ContextManager["ToolContextManager"]
Save["saveContext()"]
Load["loadContexts()"]
Select["selectRelevantContexts()"]
Pointers["Context Pointers"]
end
subgraph Storage["檔案系統 (.dexter/context/)"]
File1["AAPL_get_income_statements_abc123.json"]
File2["MSFT_get_balance_sheets_def456.json"]
File3["get_news_ghi789.json"]
end
subgraph ContextData["上下文數據結構"]
CD["toolName: string<br/>args: Record<br/>timestamp: string<br/>queryId: string<br/>sourceUrls: string[]<br/>result: unknown"]
end
Save --> Storage
Load --> Storage
Pointers --> Select
Storage --> ContextData
7.1 上下文儲存
// src/utils/context.ts
export class ToolContextManager {
private contextDir: string;
public pointers: ContextPointer[] = [];
saveContext(
toolName: string,
args: Record<string, unknown>,
result: unknown,
taskId?: number,
queryId?: string
): string {
const filename = this.generateFilename(toolName, args);
const filepath = join(this.contextDir, filename);
const toolDescription = this.getToolDescription(toolName, args);
// 提取 sourceUrls
let sourceUrls: string[] | undefined;
let actualResult = result;
if (typeof result === 'string') {
try {
const parsed = JSON.parse(result);
if (parsed.data !== undefined) {
sourceUrls = parsed.sourceUrls;
actualResult = parsed.data;
}
} catch {
// 非 JSON,直接使用
}
}
const contextData: ContextData = {
toolName,
args,
toolDescription,
timestamp: new Date().toISOString(),
taskId,
queryId,
sourceUrls,
result: actualResult,
};
writeFileSync(filepath, JSON.stringify(contextData, null, 2));
// 添加到 pointers 列表
this.pointers.push({
filepath,
filename,
toolName,
args,
toolDescription,
queryId,
sourceUrls,
});
return filepath;
}
}
7.2 工具描述生成
getToolDescription(toolName: string, args: Record<string, unknown>): string {
const parts: string[] = [];
// 添加股票代碼
if (args.ticker) {
parts.push(String(args.ticker).toUpperCase());
}
// 格式化工具名稱: get_income_statements -> income statements
const formattedToolName = toolName
.replace(/^get_/, '')
.replace(/^search_/, '')
.replace(/_/g, ' ');
parts.push(formattedToolName);
// 添加週期
if (args.period) {
parts.push(`(${args.period})`);
}
// 添加限制
if (args.limit && typeof args.limit === 'number') {
parts.push(`- ${args.limit} periods`);
}
return parts.join(' ');
// 範例輸出: "AAPL income statements (quarterly) - 4 periods"
}
7.3 LLM 驅動的上下文篩選
async selectRelevantContexts(
query: string,
availablePointers: ContextPointer[]
): Promise<string[]> {
if (availablePointers.length === 0) {
return [];
}
const pointersInfo = availablePointers.map((ptr, i) => ({
id: i,
toolName: ptr.toolName,
toolDescription: ptr.toolDescription,
args: ptr.args,
}));
const prompt = `
Original user query: "${query}"
Available tool outputs:
${JSON.stringify(pointersInfo, null, 2)}
Select which tool outputs are relevant for answering the query.
Return a JSON object with a "context_ids" field containing a list of IDs (0-indexed) of the relevant outputs.
`;
const response = await callLlm(prompt, {
systemPrompt: CONTEXT_SELECTION_SYSTEM_PROMPT,
model: this.model,
outputSchema: SelectedContextsSchema,
});
const selectedIds = (response as { context_ids: number[] }).context_ids || [];
return selectedIds.map((idx) => availablePointers[idx].filepath);
}
8. 對話歷史管理
Dexter 支援多輪對話,使用 LLM 生成摘要來實現高效的上下文檢索:
flowchart TD
subgraph MessageHistory["MessageHistory"]
Add["addMessage(query, answer)"]
Select["selectRelevantMessages(currentQuery)"]
Format["formatForPlanning()"]
end
subgraph Flow["處理流程"]
Q["新查詢"]
Gen["生成摘要"]
Store["儲存消息"]
Match["LLM 相關性匹配"]
Inject["注入上下文"]
end
Q --> Gen --> Store
Q --> Match --> Inject
Add --> Gen
Select --> Match
8.1 消息儲存與摘要
// src/utils/message-history.ts
export class MessageHistory {
private messages: Message[] = [];
private relevantMessagesByQuery: Map<string, Message[]> = new Map();
async addMessage(query: string, answer: string): Promise<void> {
// 清除相關性快取
this.relevantMessagesByQuery.clear();
// 使用 LLM 生成摘要
const summary = await this.generateSummary(query, answer);
this.messages.push({
id: this.messages.length,
query,
answer,
summary,
});
}
private async generateSummary(query: string, answer: string): Promise<string> {
const answerPreview = answer.slice(0, 1500);
const prompt = `Query: "${query}"
Answer: "${answerPreview}"
Generate a brief 1-2 sentence summary of this answer.`;
const response = await callLlm(prompt, {
systemPrompt: MESSAGE_SUMMARY_SYSTEM_PROMPT,
model: this.model,
});
return typeof response === 'string' ? response.trim() : String(response).trim();
}
}
8.2 相關消息選擇
async selectRelevantMessages(currentQuery: string): Promise<Message[]> {
if (this.messages.length === 0) {
return [];
}
// 檢查快取
const cacheKey = this.hashQuery(currentQuery);
const cached = this.relevantMessagesByQuery.get(cacheKey);
if (cached) {
return cached;
}
const messagesInfo = this.messages.map((message) => ({
id: message.id,
query: message.query,
summary: message.summary,
}));
const prompt = `Current user query: "${currentQuery}"
Previous conversations:
${JSON.stringify(messagesInfo, null, 2)}
Select which previous messages are relevant to understanding or answering the current query.`;
const response = await callLlm(prompt, {
systemPrompt: MESSAGE_SELECTION_SYSTEM_PROMPT,
model: this.model,
outputSchema: SelectedMessagesSchema,
});
const selectedIds = (response as { message_ids: number[] }).message_ids || [];
const selectedMessages = selectedIds
.filter((idx) => idx >= 0 && idx < this.messages.length)
.map((idx) => this.messages[idx]);
// 快取結果
this.relevantMessagesByQuery.set(cacheKey, selectedMessages);
return selectedMessages;
}
9. Terminal UI 架構
Dexter 使用 React + Ink 構建終端 UI:
flowchart TD
subgraph CLI["CLI 組件"]
State["AppState"]
History["CompletedTurn[]"]
Input["Input 組件"]
end
subgraph Hooks["React Hooks"]
UseAgent["useAgentExecution"]
UseQueue["useQueryQueue"]
UseApiKey["useApiKey"]
end
subgraph Views["視圖組件"]
Intro["Intro"]
Progress["AgentProgressView"]
Tasks["TaskListView"]
Answer["AnswerBox"]
Queue["QueueDisplay"]
end
CLI --> Hooks
Hooks --> Views
UseAgent --> Progress
UseAgent --> Tasks
UseAgent --> Answer
9.1 CLI 主組件
// src/cli.tsx
export function CLI() {
const { exit } = useApp();
const [state, setState] = useState<AppState>('idle');
const [model, setModel] = useState(() => getSetting('model', DEFAULT_MODEL));
const [history, setHistory] = useState<CompletedTurn[]>([]);
const messageHistoryRef = useRef<MessageHistory>(new MessageHistory(model));
const { apiKeyReady } = useApiKey(model);
const { queue: queryQueue, enqueue, shift: shiftQueue } = useQueryQueue();
const {
currentTurn,
answerStream,
isProcessing,
toolErrors,
processQuery,
handleAnswerComplete,
} = useAgentExecution({
model,
messageHistory: messageHistoryRef.current,
});
return (
<Box flexDirection="column">
{/* 靜態歷史紀錄 */}
<Static items={staticItems}>
{(item) =>
item.type === 'intro' ? (
<Intro key="intro" />
) : (
<CompletedTurnView key={item.turn.id} turn={item.turn} />
)
}
</Static>
{/* 當前進行中的對話 */}
{currentTurn && (
<Box flexDirection="column" marginBottom={1}>
<CurrentTurnView
query={currentTurn.query}
state={currentTurn.state}
/>
{answerStream && (
<AnswerBox
stream={answerStream}
onComplete={handleAnswerComplete}
/>
)}
</Box>
)}
{/* 佇列顯示 */}
<QueueDisplay queries={queryQueue} />
{/* 輸入框 */}
<Box marginTop={1}>
<Input onSubmit={handleSubmit} />
</Box>
</Box>
);
}
9.2 Agent 執行 Hook
// src/hooks/useAgentExecution.ts
export function useAgentExecution({
model,
messageHistory,
}: UseAgentExecutionOptions): UseAgentExecutionResult {
const [currentTurn, setCurrentTurn] = useState<CurrentTurn | null>(null);
const [answerStream, setAnswerStream] = useState<AsyncGenerator<string> | null>(null);
const [isProcessing, setIsProcessing] = useState(false);
const createAgentCallbacks = useCallback((): AgentCallbacks => ({
onPhaseStart: setPhase,
onPhaseComplete: markPhaseComplete,
onPlanCreated: setTasksFromPlan,
onTaskUpdate: updateTaskStatus,
onTaskToolCallsSet: setTaskToolCalls,
onToolCallUpdate: updateToolCallStatus,
onToolCallError: handleToolCallError,
onAnswerStart: () => setAnswering(true),
onAnswerStream: (stream) => setAnswerStream(stream),
}), [/* deps */]);
const processQuery = useCallback(
async (query: string): Promise<void> => {
setCurrentTurn({
id: generateId(),
query,
state: {
currentPhase: 'understand',
understandComplete: false,
planComplete: false,
tasks: [],
isAnswering: false,
},
});
const callbacks = createAgentCallbacks();
const agent = new Agent({ model, callbacks });
await agent.run(query, messageHistory);
},
[model, messageHistory, createAgentCallbacks]
);
return {
currentTurn,
answerStream,
isProcessing,
processQuery,
handleAnswerComplete,
};
}
10. 完整執行流程
讓我們追蹤一個查詢的完整執行流程:
sequenceDiagram
participant User as 用戶
participant CLI as CLI
participant Agent as Agent
participant Understand as UnderstandPhase
participant Plan as PlanPhase
participant Tools as Tools
participant LLM as LLM
participant Context as ContextManager
User->>CLI: "What was Apple's revenue growth last year?"
CLI->>Agent: processQuery(query)
Note over Agent: 階段 1: Understand
Agent->>Understand: run(query)
Understand->>LLM: 提取意圖和實體
LLM-->>Understand: {intent, entities: [AAPL, 2024, revenue]}
Understand-->>Agent: Understanding
Note over Agent: 階段 2: Plan
Agent->>Plan: run(query, understanding)
Plan->>LLM: 建立任務清單
LLM-->>Plan: {tasks: [task_1, task_2]}
Plan-->>Agent: Plan
Note over Agent: 階段 3: Execute
rect rgb(200, 230, 200)
Note over Agent: task_1: Get AAPL financials (use_tools)
Agent->>LLM: selectTools(task_1)
LLM-->>Agent: [get_income_statements]
Agent->>Tools: get_income_statements(AAPL, annual)
Tools-->>Agent: income_data
Agent->>Context: saveContext(data)
end
rect rgb(200, 200, 230)
Note over Agent: task_2: Calculate growth (reason)
Agent->>Context: buildContextData()
Context-->>Agent: contextData
Agent->>LLM: analyze(task_2, contextData)
LLM-->>Agent: analysis_result
end
Note over Agent: 階段 4: Answer
Agent->>LLM: generateFinalAnswer(query, results)
LLM-->>CLI: (streaming response)
CLI-->>User: "Apple's revenue grew 6% YoY..."
11. 設計特點總結
| 特性 | 實現方式 | 優勢 |
|---|---|---|
| 四階段架構 | Understand → Plan → Execute → Answer | 結構清晰,可觀測性高 |
| 依賴感知並行 | TaskNode 依賴圖 + Promise.all | 最大化並行效率 |
| JIT 工具選擇 | 執行時使用小模型選擇工具 | 降低成本,提高靈活性 |
| 多模型支援 | 模型工廠 + 前綴匹配 | 切換模型無縫 |
| 結構化輸出 | Zod Schema + withStructuredOutput | 類型安全,解析可靠 |
| 上下文持久化 | 檔案系統 + Pointers | 可審計,可恢復 |
| LLM 上下文篩選 | 智慧選擇相關工具結果 | 減少 token 使用 |
| 對話歷史 | 摘要 + 相關性匹配 | 支援多輪對話 |
| 終端 UI | React + Ink | 現代化終端體驗 |
| 來源追蹤 | sourceUrls 貫穿全流程 | 答案可溯源 |
12. 架構權衡分析
優勢
- 清晰的階段分離:每個階段職責明確,易於維護和調試
- 靈活的工具選擇:JIT 選擇而非預先綁定,適應性強
- 高效的並行執行:依賴圖驅動,最大化吞吐量
- 良好的可觀測性:Callbacks 機制提供完整執行追蹤
- 多模型支援:易於切換和比較不同模型
權衡
- 小模型工具選擇:可能在複雜場景下選錯工具
- 檔案系統上下文:不適合分布式部署
- 無 Checkpoint:中斷後無法恢復執行
- 單一 API 來源:依賴 Financial Datasets API
13. 使用範例
13.1 基本查詢
# 啟動 Dexter
bun start
# 查詢 Apple 營收成長
> What was Apple's revenue growth over the last 4 quarters?
# Dexter 會:
# 1. 理解:提取 AAPL、last 4 quarters、revenue growth
# 2. 規劃:建立 2 個任務
# 3. 執行:呼叫 get_income_statements
# 4. 答案:串流輸出分析結果
13.2 比較分析
> Compare Microsoft and Google's operating margins for 2023
# Dexter 會並行獲取兩家公司的數據,然後進行比較分析
13.3 切換模型
> /model
# 選擇:
# - GPT 5.1 (OpenAI)
# - Claude Sonnet 4.5 (Anthropic)
# - Gemini 3 (Google)
14. 結語
Dexter 展示了如何構建一個專業領域的自主 AI Agent:
- 不使用 LangGraph 等重量級框架,而是自己實現輕量級的四階段執行模型
- JIT 工具選擇配合小模型,平衡了靈活性和成本
- 依賴感知的並行執行,提高了效率
- React + Ink 的終端 UI,提供了良好的用戶體驗
如果你正在考慮構建特定領域的 AI Agent,Dexter 的架構是一個值得參考的範例。它展示了如何在不依賴複雜框架的情況下,實現一個功能完整、可維護的 Agent 系統。
相關資源: