开端
在使用Langfuse的过程中,官方自带的集成langGraph中,进行收集到的节点和LLM的调用信息,无法进行定制化,只要调用就收集,并且如果Graph嵌套较多,Trace收集到的日志嵌套很深,有用的信息会被循环和嵌套给“淹没”掉,并且成本是很高的,一次运行可以达到1000-1500的次数收集
所以我使用原生的langfuseSDK的方法进行定制,只收集我想要收集的,这样可以控制日志的链路同时也可以控制成本
一句话:一次注入、全链共享;只记录关键、剔除冗馀
使用自定义之后的日志收集,嵌套层级最大只有3层,并且无循环,只有干练的关键节点的信息,并且收集到的日志成本变为20-50左右,降低50倍
1. 设计目标 & 痛点
痛点 | 目标 |
---|---|
Trace 层级过深,视图臃肿 | 单 Trace 统御全链 |
全量回调,成本高 | 仅记录关键 Span + LLM Generation |
Tracing 逻辑散落业务 | 装饰器自动化,业务函数零耦合 |
代码入侵严重 | Tracing 信息深藏 metadata.tracing ,接口干净 |
2. 总览架构图
3、关键概念
- lf —
new Langfuse({...})
实例,入口处一次性创建并塞进 config - traceId — 由 Langfuse 自动生成的 UUID,贯穿整条调用链
- @GraphNode — 节点装饰器,负责
- 开 Span
- (可选)注入 LLM Generation 回调
- 收尾 Span
- withGen — 装饰器参数
true
→ 记录 LLM Generationfalse
→ 仅记录 Span
- inputKeys - span节点的Input输入
使用这个参数可以控制span节点的输入信息,从state中筛选出来我们需要查询的参数,而不是全部
4. 实现细节
4.1 入口初始化
const lf = new Langfuse({ publicKey, secretKey });
const trace = lf.trace({ userId, sessionId, tags: ['v2'] });
await graph.streamEvents(data, {
version: 'v2',
configurable: { trace, lf },
});
4.2 GraphNode 装饰器核心
// decorators/GraphNode.ts
import { CallbackHandler } from 'langfuse-langchain';
// 获取 input,只保留关心的键
function pickInput(state, inputKeys) {
if (!inputKeys?.length) return { userInput: state?.userInput };
return inputKeys.reduce((obj, key) => {
if (Object.prototype.hasOwnProperty.call(state, key)) obj[key] = state[key];
return obj;
}, {});
}
// 合并 callbacks,保证为数组
function mergeCallbacks(cfg, span, withGen) {
let callbacks = [];
if (cfg.callbacks) {
callbacks = Array.isArray(cfg.callbacks) ? cfg.callbacks : [cfg.callbacks];
}
if (withGen) {
callbacks.push(new CallbackHandler({ root: span }));
}
return callbacks;
}
//LangFuse日志收集-装饰器
export function GraphNode(name: string, withGen = false, inputKeys: string[] = []) {
return function <T extends (...args: any[]) => Promise<any>>(orig: T): T {
const wrapped = (async (...args: any[]) => {
const [state, cfg = {}] = args;
const { configurable = {} } = cfg;
const { lf, trace } = configurable;
if (!lf || !trace) {
throw new Error(`缺少 lf 信息:请确保在 metadata.tracing 中传入 lf 与 traceId`);
}
// 关心的 input
const safeInput = pickInput(state, inputKeys);
// 开 span
const span = trace.span({ name, input: safeInput });
// runtimeCfg 传递下去
const runtimeCfg = {
...cfg,
configurable: { ...configurable, trace: span },
callbacks: mergeCallbacks(cfg, span, withGen),
};
try {
const result = await orig.apply(null, [state, runtimeCfg]);
console.log('开始span结束');
await span.end({ output: result });
return result;
} catch (e) {
await span.end({ error: String(e) });
throw e;
}
}) as T;
return wrapped;
};
}
// 方法装饰器,将类方法包装为 GraphNode
export function GraphNodeMethod(name: string, withGen = false, inputKeys: string[] = []) {
return function (_target: any, _propertyKey: string, descriptor: PropertyDescriptor) {
const fn = descriptor.value;
descriptor.value = GraphNode(name, withGen, inputKeys)(fn);
};
}
4.3 节点书写范式
class SupervisorNodeHandlers {
// //1、第一步进行意图识别
@GraphNodeMethod('intentRecognitionNode', true, ['userInput', 'inputParam'])
async intentRecognitionNode(state: typeof InputStateAnnotation.State, config: any) {
const { userInput, inputParam, lf, trace } = state;
let { sessionId, messageId } = inputParam;
// 意图识别-函数执行
const result: any = await intentRecognitionModuleV2(userInput, config);
let goto = END;
return new Command({
goto: goto,
update: { intentResult: result, intentResultType, envelopeTotalAgent },
});
}
}
业务层只需 透传 **config**
,其余由装饰器托管。
4.4 子图透传策略
const result = await executeGraph.invoke(
{
plannerPartResult: currentItem,
plannerPartIndex: currentIndex,
inputParam: inputParam,
plannerResult: plannerResult,
userInput: userInput,
intentResultType: intentResultType,
envelopeTotalAgent,
runId,
},
config
);
4.5 LLM 调用规范
export async function intentRecognitionModuleV2(userInput: string, config: any) {
const chain = prompt.pipe(model).pipe(parse);
let result = await chain.invoke({ userInput: userInput }, config); // 明确指定非流式);
}
5. 总结
刚开始的实现,我是没有先考虑装饰器的,因为我喜欢函数式编程,并不是很喜欢类,并且使用装饰器要创建类,装饰器只能在类的方法上面使用,所以我刚开始考虑的并不是装饰器,而是函数内部调用功能函数就可以
但是发现不够优雅,并且节点函数本来应该只负责业务逻辑的,加过多的系统函数进行,会导致业务逻辑模糊,很大程度上会降低代码的可读性
于是我找到高阶函数的使用,在函数外面包一层函数**,如果我想要的功能过多,其实就需要包多层函数,有点像回调函数的“回调地狱”,所以我没考虑高阶函数**,到此或许函数式编程无法在满足优雅实现功能的需求啦,所以我考虑使用类中的方法来
这样创建类的方法,使用的时候只需要实例化即可,并且可以使用优雅的装饰器