我问了它一句:我的投资组合中科技股占比是多少?
选的是进取型那个客户。300 万,股票 65%、债券 15%、现金 5%、另类 15%。
跑完终端打印"处理用时 6.54 秒"。我当时的反应是:还行吧,六秒多,能接受。
然后我打开 LangSmith,点了下这条 trace。
6.43 秒。比终端那个数少 0.11 秒,是本地 print 的开销。
两个数差不多。但拆开看是另一回事。
4.38 秒判断该不该快,1.06 秒回答
Waterfall 展开是这样:
MD
assess 4.38s
Tongyi 2.73s
JsonOutputParser 0.00s
reactive 1.06s
Tongyi 1.05s
respond 0.00s
判断该不该快,4.38 秒。真正回答,1.06 秒。
四比一。
代码里,反应式那层写的是"毫秒级响应,直接调数据和预设回答"。实测 6.54 秒,其中一秒在回答。
为了决定"要不要快",花了四倍于"快"本身的时间。
如果分流规则写成正则,查询里出现"指数""点位""行情"就走反应式,这一步是 0 毫秒。
用一次 LLM 调用去判断该不该走轻量路径,本身就贵。这钱在普通查询里不显眼,落在"紧急查询"上就有点怪:用户要的是快,系统先花四秒想清楚要不要快。
还有个细节,是拿计算器减出来的。
assess 节点 4.38 秒。下面挂着两个子 span:Tongyi 2.73 秒,JsonOutputParser 0.00 秒。
我一开始以为这俩加起来就是 4.38。减了一下,2.73。差 1.65 秒。
这 1.65 秒去哪了?
trace 下面有行小字:Some runs have been hidden. Show 6 hidden runs。
点开那六个,就是这 1.65 秒------prompt 拼装、状态序列化、回调打点,还有几个内部 step。
一个节点的耗时,跟它里面那次 LLM 调用对不上。中间夹着的东西平时看不见。
不展开这六个 span,很容易以为 4.38 秒全是模型在思考。实际模型只占 62%。
这个差在调优的时候有区别。想让它快,改 prompt 只能动 2.73 秒那一块,剩下 1.65 秒是框架自己的开销,改 prompt 改不掉。
trace 顶部还有个 Trajectory 切换。Waterfall 按时间轴摊开,Trajectory 按动作顺序排。
这次的 Trajectory 只有三行:assess、reactive、respond。
三行,一次完整的用户交互。看着挺干净的。
我当时也是这么觉得的。
协调层只收到一句话
assess_query 里,喂给 LLM 的东西是这样的:
python
input_data = {
"user_query": state["user_query"],
}
一个字段。就一句话。
ASSESSMENT_PROMPT 里对应的占位符也只有 {user_query}。客户画像在 state 里躺着,没被传进去。
所以协调层判断"这个问题复杂不复杂"的时候,它不知道这个客户是谁、有多少钱、配了什么。
它看到的只有七个字:我的投资组合中科技股占比是多少?
然后它输出了这么一段理由:
该查询要求实时获取用户当前投资组合中科技股的持仓占比,属于账户/持仓状态类事实性信息,不涉及复杂推理或外部知识调用,需即时从用户持仓数据中提取并计算,符合紧急、直接、低延迟响应特征。
"需即时从用户持仓数据中提取并计算。"
问题在于,这个 Agent 没有这个能力。
reactive_processing 里注册的工具就一张表:
python
tools_map = {
"上证指数查询": query_shanghai_index,
}
一个。查上证指数的。
没有工具能读用户持仓。
它凭一句话的措辞,推断出了一个不存在的能力,然后拿这个能力做了分流决策。
这段理由写得挺好。识别出是"账户/持仓状态类事实性信息",判断出"不涉及复杂推理",推出"符合紧急、直接、低延迟响应特征"。
逻辑链完整,每一步都站得住。
除了第一步。
而这段理由,LangSmith 上看不到。它只活在终端的 print 里。
trace 上 assess 这个 span,Input 只有一个 user_query,Output 只有 processing_mode 和 query_type 两个字段。中间那两百字推理,LangSmith 不知道。
除非你把 reasoning 也塞进 state,让它跟着 trace 走。
画像就在同一个 state 里,它说访问不到
客户是 customer2。进取型,长期,目标是"财富增长"和"资产配置多元化",偏好新兴市场和高成长行业,组合 300 万。
配置:
python
"current_allocations": {
"股票": 0.65,
"债券": 0.15,
"现金": 0.05,
"另类投资": 0.15
}
股票 65%。
这些在 state 里。但反应式那条路的 prompt 里,没有 {customer_profile} 这个占位符。
python
REACTIVE_AGENT_PROMPT = """你是一个财富管理投顾AI助手,请根据用户的问题回答。
你可以使用以下工具:
- 上证指数查询: 用于查询上证指数的最新行情
如果用户询问上证指数相关信息,请在回答中包含 [CALL_TOOL:上证指数查询] 标记。
如果不需要使用工具,直接回答用户问题即可。
用户问题: {input}
请提供专业、简洁、准确的回答。
"""
只有 {input}。
所以它说"我无法访问您的投资组合数据"的时候,是真的没有数据。
LangSmith 的 Output 面板上,这两行是挨着的:
表格
| 字段 | 值 |
|---|---|
final_response |
我无法访问您的投资组合数据,因此无法确定科技股的具体占比... |
customer_profile |
{7 items} |
同一本 state 字典,上下隔了三行。一行说我访问不到,一行装着他全部的家底。
这两行摆在一起,比看十遍 prompt 都直观。
严格说,就算把画像传进去,它也答不了"科技股占比"。current_allocations 只到"股票"这一级,没细分到行业。
但它至少能说:"您当前股票仓位 65%,科技股的细分占比不在我可访问的数据范围内。"
两句话对用户的价值差得远。前面那句用了手上的信息,边界划在真该划的地方。后面那句把已知的也一起否了。
上次跑同一个问题,结果不是这样。
那次它先说"在我的知识库中没有关于您具体投资组合中科技股占比的数据",紧接着又补了一句"根据我的经验,部分私募基金对单一行业(如科技)的持仓上限常设为20%-30%"。
边界划窄了,漏了个没依据的数字出去。
这次边界划宽了,把已有的信息也一起关在门里。
同一个问题,两次跑,两次都错,错的方向正好相反。
我一开始以为是 prompt 写得不好,改改就完事了。后来发现不是这么回事。这个 Agent 的"知识边界",每次运行都是临时划的,划在哪儿取决于模型当时怎么想。
判成紧急,紧急通道是空的
Output 里还有个字段:query_type: emergency。
state 定义里有这么一行:
python
emergency_response: Optional[Dict[str, Any]] # 紧急响应结果
初始化给了 None。
整个 702 行里,它只出现过这两次。
系统判了个"紧急"出来,而专门留给紧急查询的那个槽,从建好到现在一直是空的。
实际路径是 assess → reactive → respond。判成 informational 或者 analytical,走的也是这一条。区别只在终端里打印的那行字。
所以 query_type 这个三分法,在代码里就是个标签。标签和路由是两回事。
真正决定走哪条路的,只有 processing_mode 一个字段。
这点我当时没意识到。看到 query_type 是 emergency,我还以为走了什么专门的紧急通道。
current_phase 从头到尾没动过
Input 面板上 current_phase 是 assess。Output 面板上,还是 assess。
6.43 秒,三个节点跑完,这个字段一动没动。
它不是摆设。state 里它归在"控制流"那一组。深思熟虑那条路的节点都在维护它------collect_data 成功设成 "analyze",出错设回 "collect_data";analyze 成功设 "recommend",出错设回 "analyze"。
一套完整的"下一步该去哪"的指针。
但 assess_query 没设它,reactive_processing 也没设。
走快路的时候,这指针从头到尾停在入口。
真要在中途"回到某个阶段重来",反应式这条路没有位置可回。它不知道自己刚才在哪。
上一个 case 那个投研 Agent 是另一个极端:current_phase 每个节点都在更新,出错还写回上游,可图是直线,写回去了没人接。
一个把位置写进了状态,图却是直线。另一个图里有条件边,位置这个字段干脆没人管。
两个都没走完。
两条虚线,一个黑箱
跑之前,LangGraph 把流程图打印成了 Mermaid:
js
__start__ --> assess;
assess -.-> collect_data;
assess -.-> reactive;
collect_data --> analyze;
analyze --> recommend;
recommend --> respond;
reactive --> respond;
respond --> __end__;
注意 assess 那两条边,箭头是 -.->,虚线。
实线是固定边,虚线是条件边。对应的代码:
python
workflow.add_conditional_edges(
"assess",
lambda x: "reactive" if x.get("processing_mode") == "reactive" else "collect_data",
{
"reactive": "reactive",
"collect_data": "collect_data"
}
)
整张图就这两条虚线。整个架构的赌注,也全在这两条线上。
直线的问题是退不回去,分叉的问题是有可能走错。
走哪条,取决于 assess 那一步的输出。而那一步的输入只有一句话。
Mermaid 画的是"可能走哪几条路",trace 画的是"这次实际走了哪条"。
两张图放一起才完整。Mermaid 上八个节点八条边,这次走过的只有三个。
差的那部分,是没被触发的分支。它们还在,只是没走到。
兜底逻辑全指向"快",而 trace 上看不出区别
assess_query 里有一段异常处理:
python
processing_mode = result.get("processing_mode", "reactive")
if processing_mode not in ["reactive", "deliberative"]:
processing_mode = "reactive" # 默认使用反应式处理
query_type = result.get("query_type", "emergency")
if query_type not in ["emergency", "informational", "analytical"]:
query_type = "emergency" # 默认为紧急查询
取不到值,走反应式。值不合法,还是走反应式。
查询类型同理,取不到当紧急,不合法也当紧急。
两个默认值都指向最快的那条路。
这么写不算错。宁可快而糙,不要慢而卡。大多数查询确实简单,兜底走快路,平均体验更好。
问题在它是隐式的。
LLM 输出合法值,流程正常走;LLM 输出崩了,兜底走快路,日志里看不出区别------因为兜底之后的 processing_mode 也是 "reactive",跟正常判出来的一模一样。
LangSmith 上更看不出来。这个 span 的 Output 就是 reactive,它不会标注"这是兜底兜的"。
除非你在兜底那个分支里加个 tag,比如 ls_tags=["fallback"]。
不然这个信息在整条链路上就没了,事后查不出来。
标签和元数据是动态注入的,这点做得挺不错
这条 trace 上挂了四个 tag:
customer-customer2、hybrid-agent、wealth-advisor、进取型
最后那个不是写死的。代码里这么来的:
python
tags=[
f"customer-{customer_id}",
"hybrid-agent",
"wealth-advisor",
customer_profile.get("risk_tolerance", "unknown")
]
客户画像里的 risk_tolerance 直接变成 tag。
这样在 LangSmith 上就能按"进取型"筛,把这类客户的调用全拎出来看。也能按客户 ID 筛。业务代码一行没动。
metadata 里还挂着 customer_id、investment_horizon。另外两个 ls_integration 和 ls_run_depth 不是我加的,是 LangSmith 自己补的,用来标这条链路是怎么接进来的。
这块用着确实顺。LangChain 和 LangSmith 是一家的,RunnableConfig 里塞的东西框架直接吞了,节点进出自动打点。
换个框架就得自己一项项埋。
prompt 还硬编码在 py 文件里,这一步可以挪上去
查出来的这几处,最好改的是 REACTIVE_AGENT_PROMPT 少了 {customer_profile}。
加个占位符,把画像拼进去,两行代码。
改完怎么知道真的好了?
现在只能靠肉眼看。跑一遍,觉得"嗯,这次说了 65%",就算过了。
LangSmith 的 Prompts 面板能把这个流程规范一点。prompt 传上去,起个名,改一版存一版,每次都有记录。
跑测试的时候用 experiment_prefix 区分版本:
python
1
2
experiment_prefix="prompt-v2-with-profile"
Experiments 页面上会并排列出 v1 和 v2 的结果,一个用例一行,谁好谁坏一眼能看出来。
不用自己写脚本对比,也不用记上次跑的是哪一版。
代价是 prompt 得从代码里挪出去。本地调试多一次网络请求,改个词要开网页。
自己一个人写的时候这套有点重。但要是 prompt 会反复调,或者不止一个人动它,早晚得挪上去。
一条 trace 能做的最后一件事,是变成一条测试样本
LangSmith 右上角有个 +,点开是 Add Example to dataset。
Inputs 自动填好了,就是这次的 user_query 和 customer_id。
Reference Outputs 那栏空着,留给你写。
如果我写"回答应至少说明客户当前股票仓位为 65%,并明确科技股细分占比不可得",那这条样本就永远盯着这个毛病。下次改完 prompt 再跑,它要是又冒出一句"我无法访问",对比一下就知道。
存得多了,这个 Dataset 就有点像把尺子。
界面上管它叫"黄金测试集"。
黄金这两个字,工具给不了。得你自己一条条挑。
它只能把一条 trace 变成带参考答案的样本。参考答案那一栏,是空的。
最后
左侧的 Status 筛选里,这条 trace 是 success。绿勾。
没有 error,没有 interrupted,没有 pending。
六秒半,三个节点,一次完整的调用。从系统的角度看,这是一次干净的、成功的执行。
用户那边不是。
LangSmith 在这条链路上干的事,说到底就一件:把一次黑箱调用拆成输入和输出。
拆开之后才看得见 4.38 和 1.06 那个比例。也才看得见 final_response 和 customer_profile 挨在一起的那种别扭。
还有 current_phase,6.43 秒里一动没动。
这些东西在终端日志里都没有。日志只会告诉你进入了 assess、进入了 reactive、处理用时 6.54 秒。
它不告诉你这些时间花得值不值。
也不告诉你那个 reactive 是判断出来的,还是兜底兜的。
LangSmith 能回答发生了什么。应不应该,它答不了。
后面那半截得靠另一套东西:评估器、测试集、评分。
那是另一个 case,单独写一篇。
不过有个问题可以先放着:这次的回答,丢进评估器会得多少分?
模式判的 reactive,符合"紧急查询"的定义。回答里"科技股""占比"也都在。
我猜分数不会低。
可用户一个字的有用信息都没拿到。