扁平Span列表 → 还原Trace树
场景:LangSmith/LangFuse收到一批扁平的span数组,每一条只有 run_id/span_id、parent_run_id/parent_span_id,没有嵌套结构。
目标:根据id关系,在服务端把扁平列表重建为树形结构,用于页面渲染Trace视图。
原始扁平数据示例
python
spans = [
{"span_id": "A", "parent_span_id": None, "name": "trace_root"},
{"span_id": "B", "parent_span_id": "A", "name": "agent_node"},
{"span_id": "C", "parent_span_id": "B", "name": "tool_call"},
{"span_id": "D", "parent_span_id": "B", "name": "llm_call"},
]
逻辑树:
A(root)
└── B
├── C
└── D
算法两步走
- 构建id映射字典(索引):key=span_id,value=span对象,同时给每个对象增加一个空children数组。O(n)
- 遍历所有span,把自己挂载到父节点的children数组里。O(n)
- parent为
None的就是根节点,根就是整条Trace。
Python完整可运行还原代码
python
from dataclasses import dataclass
from typing import List, Optional
@dataclass
class Span:
span_id: str
parent_span_id: Optional[str]
name: str
children: List["Span"] # 用于重建树之后存放子节点
# 收到的扁平原始数据
raw_data = [
{"span_id": "A", "parent_span_id": None, "name": "trace_root"},
{"span_id": "B", "parent_span_id": "A", "name": "agent_node"},
{"span_id": "C", "parent_span_id": "B", "name": "tool_call"},
{"span_id": "D", "parent_span_id": "B", "name": "llm_call"},
]
def build_trace_tree(raw_list) -> Optional[Span]:
# 1. id -> span 的索引map
id_map = {}
for item in raw_list:
span = Span(
span_id=item["span_id"],
parent_span_id=item["parent_span_id"],
name=item["name"],
children=[]
)
id_map[span.span_id] = span
root: Optional[Span] = None
# 2. 遍历,找父节点,挂载到父的children
for span in id_map.values():
pid = span.parent_span_id
if pid is None:
# 根节点
root = span
else:
# 通过map快速找到父span,把当前span加入父的children
parent = id_map.get(pid)
if parent:
parent.children.append(span)
return root
def print_tree(node: Span, depth=0):
"""递归打印树,方便看结果"""
prefix = " " * depth
print(f"{prefix}- {node.name}({node.span_id})")
for child in node.children:
print_tree(child, depth+1)
if __name__ == "__main__":
trace_root = build_trace_tree(raw_data)
print_tree(trace_root)
输出:
- trace_root(A)
- agent_node(B)
- tool_call(C)
- llm_call(D)
重点:
原始上报过来的数据根本没有children字段 。
children是服务端内存里临时构造出来,只用于UI渲染;数据库存储依旧存扁平一条条span。数据库不会存树形嵌套JSON。
对应LangSmith真实场景
- Python客户端本地:生成一个个独立Run(Span),互相之间只有parent_run_id关联,本地不维护children树。
- 通过HTTP批量POST,一条条Run上报LangSmith后端。
- LangSmith后端存入数据库,每一行是一个run记录。
- 用户打开网页看这条trace:后端查询数据库,把该trace_id下所有run全部查出来得到扁平列表。
- 执行上面这个
build_trace_tree算法,内存组装树,返回给前端渲染树形链路图。 - 请求结束,内存的树直接丢弃;数据库仍然是扁平存储。
那如果链路被取消(前端停止生成)会发生什么?
假设:D(llm_call)被task.cancel,只上报start,没有end事件,甚至D根本没上报上来 。
数据库里只有 A B C。
传入build_trace_tree的列表缺少D。
最后渲染出来的树:
‑ trace_root(A)
‑ agent_node(B)
‑ tool_call(C)
# D直接消失/或者只存在一条残缺、未结束的D记录
页面看到链路断掉,就是因为部分span没有上报到服务端,扁平列表缺少节点,树就残缺。
边界情况
- 顺序无关:不管传入的spans顺序是A,B,C,D还是D,C,B,A,id_map哈希表可以正确找到父节点,树构建不受数组顺序影响。
- 父节点丢失:某个span的parent_span_id指向一个不存在的id,这个节点就不会挂载到树上,会成为游离孤儿节点。平台会做兼容处理,把孤儿节点挂到根下展示。
和你整套知识串联总结
- Python函数是对象 → 回调handler可以传递,各个生命周期触发创建span。
- contextvars协程上下文:本地只保存当前栈顶span,拿到父span_id,生成子span。
- 本地每个span独立上报,本地没有完整树。
- 数据库存储扁平一条条span记录。
- 查询时,服务端做id映射 + 挂载children,内存临时还原Trace树给前端展示。
- 部分span没上报,还原出来的树就残缺,就是停止生成看到的现象。
LangFuse、OpenTelemetry后端,还原Trace树,用的几乎一模一样的算法。