Skip to content

完整查询示例 — 一次 trace 拆解

用 v0.1.1 跑一次「查 600519 的当前价格 + 最近一季 ROE」,把每一步的输入、输出、cite 全展开。

所有数字、tool_call_idfetched_at 都来自 2026-05-07 21:42 BJT 的真实运行(盘后路径)。把它当作 reference 文档而不是教程。

用户输入

查 600519 的当前价格和最近一季 ROE

Step 1 — Skill 选择

Hermes LLM 根据 ~/.hermes/profiles/stock-research-agent/skills/research/stock-research/SKILL.md 里的触发条件("用户提到 6 位数字股票代码 + 财务问询")选择 stock-research skill。

LLM 决定调用两个工具:

  • fetch_price.py 600519
  • fetch_fundamentals.py 600519 --period 20251231

Step 2 — fetch_price.py 600519

入口skill/stock-research/scripts/fetch_price.py):

python
client = get_client()                                # DirectClient (无 TWILIGHT_SERVICE_URL)
envelope = client.get_price(normalize_code("600519"))  # → "600519.SH"
emit(envelope)

_client.DirectClient.get_price 转入 core/core/data/schemas.py:get_price:

python
def get_price(code: str, *, trade_date=None, token: str):
    if trade_date is None and _market.is_market_open(token=token):
        try:
            return akshare.get_spot_price(code)        # 路径 A: 实时
        except AkshareError:
            pass                                        # 降级走 Tushare
    # 路径 B: Tushare daily
    items = tushare.call(api_name="daily", ...)
    return _claim(..., source="tushare", served_by="tushare")

_market.is_market_open 检查:

  • 当前北京时间:2026-05-07 21:42:31(Thursday)
  • 21:42 > 15:00 → 超出交易时段
  • 直接返回 False不调 Tushare trade_cal

→ 走路径 B(Tushare daily)。

Tushare 调用:

http
POST http://api.tushare.pro
{
  "api_name": "daily",
  "token": "<TUSHARE_TOKEN>",
  "params": {"ts_code": "600519.SH"},
  "fields": "ts_code,trade_date,close"
}

返回:

json
{
  "code": 0,
  "msg": "",
  "data": {
    "fields": ["ts_code", "trade_date", "close"],
    "items": [["600519.SH", "20260507", 1371.05]]
  }
}

stdout 输出(fetch_price.py 的最终 envelope):

json
{
  "value": 1371.05,
  "metric": "close",
  "code": "600519.SH",
  "as_of": "2026-05-07",
  "cite": {
    "kind": "tool",
    "source": "tushare",
    "table": "daily",
    "fetched_at": "2026-05-07T13:42:31.168698+00:00",
    "tool_call_id": "tc_fed71513e34b",
    "served_by": "tushare"
  }
}

关键观察

  • metric: "close"served_by: "tushare" → agent 必须说 "最近收盘",不能说 "当前价"
  • as_of: "2026-05-07" 是 Tushare 返回的最新行;如果是周末调用,会是上周五

Step 3 — fetch_fundamentals.py 600519 --period 20251231

core/core/data/schemas.py:get_fundamentals,调 Tushare fina_indicator

http
POST http://api.tushare.pro
{
  "api_name": "fina_indicator",
  "params": {"ts_code": "600519.SH", "period": "20251231"},
  "fields": "ts_code,end_date,roe,grossprofit_margin,netprofit_margin,debt_to_assets"
}

stdout 输出(截短,仅展示 ROE 那一条 claim):

json
{
  "code": "600519.SH",
  "as_of": "2025-12-31",
  "claims": [
    {
      "value": 36.21,
      "metric": "ROE",
      "code": "600519.SH",
      "as_of": "2025-12-31",
      "cite": {
        "kind": "tool",
        "source": "tushare",
        "table": "fina_indicator",
        "fetched_at": "2026-05-07T13:42:33.514220+00:00",
        "tool_call_id": "tc_8a1a44b21fbb"
      }
    },
    { /* gross_margin, net_margin, debt_to_assets 同结构 */ }
  ]
}

注意:get_fundamentals 暂未带 served_by(Tushare 是这条数据的唯一源,没有路由分支需要表达)。P1.2 多源后会补上。

Step 4 — Verifier 把关

core/core/verifier.py:Verifier.verify(claims, trace, registry) 做以下检查(per claim):

  • cite.kind == "tool" → 在 trace 里找 cite.tool_call_id,找不到 = fail
  • 找到的 ToolCall 记录的 value 必须与 claim 的 value 等值(浮点 abs_tol=1e-9)
  • cite.source 与 ToolCall.source 一致
  • as_offetched_at 不超过 staleness budget(默认 365×10 天)
  • cite.kind == "competence"competence_id 必须在 CompetenceRegistry 里注册

任何一项失败 → VerificationResult(ok=False, failures=[...])

python
class VerificationFailure:
    claim_index: int
    reason: str  # "tool_call_id 'tc_xxx' missing from trace"

SingleAgentRuntime.run() 看到 ok=False → 把 last_failures 拼到 user prompt 里,重试一次。两次都失败 → 返回 VerifiedFailure 给 Hermes,Hermes 把错误告诉用户(不静默降级)。

Step 5 — Agent 转写为自然语言

LLM 拿到两个 verified envelope,依据 SKILL.md templates/operation-strategy.md 模板组装输出:

markdown
## 600519 (贵州茅台) 速览

- **最近收盘**:¥1371.05(Tushare daily, 2026-05-07)
- **ROE (FY2025)**:36.21%(Tushare fina_indicator, 2025-12-31)

> 注:当前为盘后查询,价格为最新收盘价,非实时盘口。

强制纪律(SKILL.md §Output):

  • 每个数字必须能映射回某个 cite envelope(tool 或 competence)
  • 不能"凭感觉" — 没有 cite 就不能写
  • metric: close 用"最近收盘"措辞,metric: current_price 用"当前价"

如果 Verifier 失败会怎样?

构造一个失败场景:LLM 编造了一个数字 value: 1500cite.tool_call_id 来自一个真实的 trace 调用(值是 1371.05)。

Verifier 一次扫描:

python
VerificationResult(
  ok=False,
  failures=[VerificationFailure(
    claim_index=0,
    reason="value mismatch for tc_fed71513e34b: claim=1500.0, trace=1371.05"
  )]
)

SingleAgentRuntime 把这个反馈塞回下一轮 prompt:

Your previous answer was rejected. Reason: value mismatch for
tc_fed71513e34b: claim=1500.0, trace=1371.05. Re-run the relevant
tool and use the actual returned value, or remove the claim.

LLM 重试。仍失败 → user 看到的不是 1500,而是错误说明 + 建议("请稍后重试或提供更具体的时间范围")。

为什么这套设计值得

没有这套机制会怎样我们的做法
LLM 说"P/E 是 25",没人知道是从哪查的、什么时候查的每个数字必须有 tool_call_id 指向真实的 trace 记录
老的研报里说 28,新数据是 25,LLM 混着用as_of 强制 + staleness budget
一个 LLM 让另一个 LLM "校验",幻觉互相确认Verifier 是确定性代码,没有任何 LLM 参与
数据源换了,所有调用方都要改core 数据层抽象 + cite 信封 schema 稳定
盘中查询拿到隔夜收盘还说"当前价"served_by + metric 双重区分(v0.1.1 hotfix)

进一步阅读

团队内部文档