Dexter 深度解析:自主金融研究 AI Agent 的工程實現


0. 什麼是 Dexter?

Dexter 是一個自主金融研究代理,能夠理解用戶的金融問題,自動規劃研究步驟,使用即時市場數據執行分析,並生成數據驅動的答案。它的設計理念類似於 Claude Code,但專門針對金融研究領域。

核心能力

  1. 智慧任務規劃:自動將複雜查詢分解為結構化研究步驟
  2. 自主執行:選擇並執行正確的工具來獲取金融數據
  3. 依賴感知並行:根據任務依賴關係並行執行
  4. 即時金融數據:存取損益表、資產負債表、現金流量表等
  5. 多模型支援: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多模型支援
SchemaZod類型驗證與結構化輸出
語言TypeScript類型安全
金融 APIFinancial 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 使用
對話歷史摘要 + 相關性匹配支援多輪對話
終端 UIReact + Ink現代化終端體驗
來源追蹤sourceUrls 貫穿全流程答案可溯源

12. 架構權衡分析

優勢

  1. 清晰的階段分離:每個階段職責明確,易於維護和調試
  2. 靈活的工具選擇:JIT 選擇而非預先綁定,適應性強
  3. 高效的並行執行:依賴圖驅動,最大化吞吐量
  4. 良好的可觀測性:Callbacks 機制提供完整執行追蹤
  5. 多模型支援:易於切換和比較不同模型

權衡

  1. 小模型工具選擇:可能在複雜場景下選錯工具
  2. 檔案系統上下文:不適合分布式部署
  3. 無 Checkpoint:中斷後無法恢復執行
  4. 單一 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 系統。

相關資源