普通视图

发现新文章,点击刷新页面。
今天 — 2026年4月29日首页

2026 年,AI 全栈时代到了,前端简历别再只写前端技术了 🫠🫠🫠

作者 Moment
2026年4月28日 20:29

大家好 👋,我是 Moment,目前正在使用 Next.js、NestJS、LangChain 开发 DocFlow。这是一个面向 AI 场景的协同文档平台,集成了基于 Tiptap 的富文本编辑、NestJS 后端服务、实时协作与智能化工作流等核心模块。

在这个项目的持续打磨过程中,我积累了不少实战经验,不只是 Tiptap 的深度定制、编辑器性能优化和协同方案设计,也包括前端工程化建设、React 源码理解以及复杂项目架构实践。

如果你对 AI 全栈开发、文档编辑器、前端工程化或者 React 源码相关内容感兴趣,欢迎添加我的微信 yunmz777 一起交流。觉得项目还不错的话,也欢迎给 DocFlow 点个 star ⭐

最近我给一些前端方向的实习生做内推,看了不少简历。投递里常能看出一种预设,找实习只要把前端做熟,页面和接口能啃下来,似乎就踩对了主线。初筛读多了会有另一种感受,当业务和岗位已经大量贴上大模型、RAGAgent 时,纯前端技术栈写得再工整,也很难在一叠写法雷同的简历里单独把人托起来。

我想写的不是简历技巧,评审更常问的是,你有没有把智能接进一条可维护、可观测、也能和人协作的链路,还是只在项目名里多写几个关键词。

许多项目经历段落,技术名词很全,叙事却薄。写得多的几种模式是:

  • 周期很短,却同时堆上 RAG、流式输出、鉴权与复杂治理,读者很难估计真实投入与掌握深度
  • 段落像功能清单,缺少场景、难点、个人职责与可验证结果,性能数字也缺少口径与复现方式
  • 形态集中在教程型对话台或后台管理,技术栈高度同质,差异化不明显
  • Agent 被写成调模型、接工具,很少触及运行时、状态机、评测、观测与人在回路

还有一种写法,整段都在堆大家简历里常见的工程项,例如 JWT、双 tokenRBAC、请求拦截器、SSEWebSocket 流式、分片上传、虚拟列表。十条里七八条读起来像同一篇教程拆出来的,名词齐了,却看不出你解决的是哪一道别人没写清楚的难题。基本功当然要会,可若差异化只停在这一层,筛简历的人很难把你从同质化描述里拎出来。

简历上的项目经历一撞脸,筛简历的人就只好盯你有没有把系统做实。后面我按层写。

这两年简历里写 Agent 的人越来越多,细看实现却常常撞脸,核心路径几乎总是同一条。

接收用户输入,调用大模型,解析工具调用,执行工具,返回结果。

引用里那几步,无非是调模型、解析工具调用、执行、再把结果塞回模型。脚手架和跟练多了,半天跑通一个 Demo 很常见。评审里想听的,往往是上线以后要应付的那些事,超时、重试、成本、人要确认、出了事故怎么查和改,你有没有提前铺过路。

面试里真正该往下问的,早就不该是这种技能清单:

  • 你会不会调 LLM
  • 你会不会接 Tool
  • 你会不会用 LangChain

而是更底层的这个问题:

你有没有把 Agent 当成一个系统,而不是一个函数调用?

能讲清楚这一句,面试官才会继续往下问。落地时大家会盯这几件事有没有做实,有没有进真实产品而不是停在演示分支:

  • 有没有独立的 Agent Runtime
  • 有没有显式状态机驱动的 Agent Loop
  • 有没有把评测做成回归闸门
  • 有没有把观测、检测、红队、安全、成本和用户干预整成闭环
  • 能不能把这些能力真正接进产品,而不是停留在一段演示代码

缺了这些,名字再响亮,多半仍是一个带工具调用的聊天接口。本文不写怎么接 OpenAI、怎么声明 Tool,只写骨架。

从 Demo 到产品,Agent 系统到底还差哪几层骨架。

下面按层拆开说明。

为什么很多 Agent 项目能跑,但没有技术区分度

很多人会以为把大模型、多轮、ToolMemoryRAG 勾齐,项目就算做完了。那点东西多半只盖住 demo 里的顺利路径,一遇到真实流量,缺的是运行时、安全、观测和评测这一整圈骨架,而不是再多接一个模型。

它们看起来像 Agent,实际上更像一个带工具调用的聊天接口。

一放量,问题会先挤在几块地方,很少单靠改一段 Prompt 就能压住:

  • 同一类提问有时成有时败,工具忽对忽错
  • 用户只看到转圈,不知道卡在推理还是在等工具
  • 线上成功率掉了,分不清是模型、工具还是外部 API
  • Prompt 改完自测像变聪明,线上指标却掉头
  • 偏航多步也没有让人半途介入的口子
  • token 和钱烧在哪些步骤,心里没底

根本原因多半不在 Prompt 花不花或 Tool 多不多,而在下面几块是否长期空白:运行时、状态机、可观测、评测、风险、HITLStreaming、成本、异常和线上告警。填不全,项目就会一直像在交课堂作业。只会用 LangChain 也不等于会做 Agent,框架主要管编排,编排以外的那一圈才是评审想听你讲清楚的。

前端加 LangChain 开发者的真正优势

前端背景再叠上 LangChainLangGraph、工具与记忆,常被低估。和算法岗比的不是论文厚度,而是能不能把智能体做成别人能长期点着用的产品。

你更占便宜的地方,是把 Agent 做成用户看得见、停得下来、出了问题能对上账的系统。

模型只是其中一个节点。好不好用还要看,现在在干什么、为什么选这个工具、失败怎么补、用户能不能打断、高风险要不要确认、耗时和钱能不能对上账、改完 Prompt 有没有回归、输出能不能验、输入和工具参数有没有护栏、线上有没有告警。这些早就不是单次推理,而是一整条链路的工程活。状态、异步、中间态、确认、埋点和展示,本来就是前端日常,这块你会比只写脚本的人更顺手。

介绍自己时不必缩成会接某家 API 的前端,可以收成一句实在话。

我负责把 LLM 编排、工具、状态机、可观测、评测和交互收成一条,给人用的是产品不是脚本。

交付物也更该像任务控制台,把推理、调工具、等确认、报错恢复这些阶段摊开,而不是聊天框里一条接一条的气泡。

把 Agent 理解成运行中的系统而不是调用链

先把问题问清楚,这东西在产线里更像一次短请求,还是像一趟要跑很久的任务。

不要把 Agent 理解为一次请求的处理流程,而要把它理解为一个持续运行的任务系统。

普通接口大约四步,请求进来,处理完就结束。Agent 更像长跑任务,中间状态多。下图是一条典型阶段划分,提醒自己别再用短接口的思维去估长任务。

20260423093052

图的意思很直白,Agent 更接近任务引擎,而不是只吐一段字的接口。接下来这些问题躲不掉,状态放哪、刷新后怎么续、工具超时重试还是交给人、高风险要不要审批、token 快顶了还跑不跑、工具能不能并行、连走几步没进展算不算打转。这些都算系统设计,不是多写两行 Prompt 能糊过去的。

一个有区分度的 Agent 系统应有的分层

Agent 如果只停在调模型、调工具,听起来总像缺一块。动手前最好先想清楚,界面交互、流程编排、运行时控制、安全治理、可观测和评测各自归谁管,别全糊进一条链。

对外说法可以很简单,Agent 像一条流水线:

用户输入 → 模型推理 → 调用工具 → 返回结果

真要拆职责,可以收成六层:

  • 交互层:用户看得见、点得着的界面,负责步骤展示、审批、中断、重试和结果反馈
  • 编排层:用 LangChainLangGraph 等把 Prompt、模型、工具、记忆和状态流转组织成可维护的流程
  • 运行时 Harness:管理步数、超时、预算、快照、重试、取消和收尾,决定任务如何真正跑完或安全停下
  • 安全与检测层:在输入、工具执行前、输出和轨迹上做规则与模型检测,拦住不该发生的行为
  • 可观测层:用 Trace、Metrics、日志把每一步变成可查询、可对比、可回放的事实
  • 评测层:通过离线集、回归闸门和线上灰度,用数据判断一次改动到底有没有变好

编排层按意图出计划和工具调用,运行时层在预算、超时和状态约束下执行。执行中安全层可能拦截或要审批,可观测层记全程,评测层再拿这些记录去约束下一版 Prompt、节点、工具和发版节奏。

分层不是为了把图画复杂,而是别把运行时、安全、回放、评测和灰度都指望 LangChain 自动搞定。框架主要管编排,编排外那一圈才决定工程含量。

用户意图从交互层进编排层,编排层再把可执行步骤交给运行时。

image.png

运行时不只是把流程跑完,还要在执行过程中持续接入安全检测和可观测能力。

image.png

可观测记录下来的事实,会进入评测体系,再反向约束下一轮编排和发布策略。

image.png

每层用一句话带过,细节可以另写。

交互层负责摊开给人看、给人控。过程可见、风险写操作前要确认、能中断和改向,这些要和运行时对齐,别事后在文案里补两行提示。

编排层用 PromptToolMemory、图或链把节点串起来。LangChain 一类框架主要管这一层,观测、中断、预算、版本和 bad case 回流多半在编排之外,别把欠账算在框架头上。

运行时层用 Harness 钉死步数、单步和整体超时、token 和墙钟预算、取消和收尾。结束由状态和 Harness 判定,预算触顶该降级就降级,别指望模型自己说做完了。

安全与检测要盖住输入、工具执行前、输出和轨迹。模型吐出来的 tool call 只是草稿,执行前要走白名单、schema、权限和风险分级,高危路径该审批就审批。

可观测层靠 Trace、分层指标和结构化日志,把一次任务从猜变成查,后面才好做归因、回放和调参。

评测层用离线用例、回归闸门和线上灰度回答有没有变好。Prompt、模型、工具或状态机一动,就该自动对比基线,线上反馈要能回灌进用例集。

六层都沾到,才像能交给别人托管的产品,而不是只证明链路能跑通的 Demo

Agent Loop 应该是显式状态机而不是 while 循环

最朴素的 Agent Loop 是反复调模型、判断是否调工具、拿结果再回模型,直到产出答案。这个流程能跑通,但一进真实场景就容易失控,因为很多关键分支塞不进一个裸 while 循环。

真正容易栽跟头的几件事:

  • 工具失败后的重试与止损
  • 高风险动作前的人工确认
  • 预算触顶后的降级与收尾
  • 上下文过长时的压缩与续跑
  • 用户中断后的恢复与回放

Loop 收成显式状态机,让系统在 ReasoningToolSelectingExecutingAwaitingConfirmationRecoveringFinalizing 等状态之间按条件跳转,分支写在表里,比藏在 if 里好查也好测。

状态机写清楚以后,日常会顺很多。状态一眼能看见,分支不再散在 if 里,前后端对得上号,暂停、恢复、撤销、重试也好接。

把它和前面的 AgentHarness 组合后,职责会更清晰:

  • Harness 负责时间、步数、token、取消和强制收尾
  • 状态机负责业务语义、路径选择和异常分支

上线以后,Loop 往往还要挂审批、检测、回滚、埋点和评测,能挂在状态切换点上就别散在业务代码里。

收个尾,Agent Loop 不该只是会转的循环,最好收成一台可解释、可干预、可恢复、也能审计的状态机。

把人设计进系统而不是把人当兜底

很多团队把 HITL 理解成出错后的兜底,这会让人机协同长期停在救火阶段。设计阶段就把哪些动作自动放行、哪些必须确认、哪些默认拒绝写清楚,比上线后救火省事。

HITLHuman-in-the-loop,意思是把人放进关键决策回路。系统负责执行与提议,人负责在高风险节点确认、纠偏和兜底。

风险分级可以先从三档起步,阈值和白名单由业务与合规共同维护:

  • 低风险,默认自动执行,失败后可重试或降级,例如搜索文档、读取代码、查询只读数据、整理摘要
  • 中风险,可自动执行但要留痕,并保留撤销窗口,例如文案修改、批量替换、工作区文件编辑
  • 高风险,执行前必须阻断并等待确认,例如删除文件、外网请求、代码提交、数据库变更、发布和付费接口调用

差别通常不在有没有确认按钮,而在卡片里给不给够决策信息。动作是什么、为什么动、影响范围、能不能撤销、有没有备选,最好一眼能看完,别只剩一句是否继续。

审批如果只在前端拼文案,很快会和真实执行脱节。更省事的做法是把审批收成结构化数据,从后端下发,挂到同一条 trace 上,事件流里推 approval_required,回放、审计和告警都读同一份。

卡片上最好有:

  • 风险等级、审批时限、发起来源一眼可见
  • 受影响资源和变更范围可展开查看,必要时接 Diff
  • 可逆操作提供 Undo 入口和预计回滚成本
  • 支持改参数或切换替代动作后再执行,减少往返沟通
  • 全量记录审批人、审批理由、执行结果,满足审计留痕

审批、trace、状态机和观测如果能共用一套模型,人机协同就不只是打补丁。

Streaming 应该让 Agent 过程可见

流式输出如果只用来更快吐 token,对 Agent 任务帮助有限。用户更想知道现在卡在哪一步、工具在干什么、要不要自己点一下。

事件可以粗分三类,最好走同一条推送通道,省得前端接好几套协议:

  • token 层,持续输出自然语言内容
  • step 层,推送每一步的动作、工具状态和中间结论
  • progress 层,推送总进度、耗时和成本,减少等待焦虑

用一条联合类型把字段钉死,前后端少扯皮。下面是个示意,覆盖状态变化、工具起止、审批、进度和收尾,载体可以用 WebSocketEventSource

type AgentStreamEvent =
  | { type: "state_changed"; state: AgentState; at: number }
  | { type: "token"; text: string; stepId: string }
  | { type: "tool_started"; stepId: string; tool: string; args: unknown }
  | { type: "tool_finished"; stepId: string; ok: boolean; summary: string }
  | { type: "approval_required"; request: ApprovalRequest }
  | { type: "progress"; done: number; total: number; costUsd: number }
  | { type: "final"; answer: string; traceId: string }
  | { type: "error"; message: string; recoverable: boolean };

协议统一以后,时间线、步骤卡片、进度条和审批弹窗才好做,中间态不必全塞进气泡里。界面上比较值得先做的几件事:

  • 步骤折叠与展开,避免长任务刷满屏幕
  • Observation 面板分层展示工具入参、结果摘要、原始返回
  • 工具日志实时滚动,失败步骤高亮并给出重试入口
  • 全局状态浮层显示当前状态机节点与等待原因
  • StopRetryContinue、插话打断与后端取消契约对齐
  • 人工接管入口用于切换执行策略或直接改写下一步
  • 最终答案和中间证据联动,点击引用可回跳对应 step

同样是等三十秒,转圈和看着系统一步步推进,感受差很多。过程可见,用户能更早纠偏,也能少烧不少无效 token

离线评测资产、线上观测与可迁移遥测

Agent 要长期迭代,既要离线侧能证明有没有变好,也要线上侧能看见真实流量里发生了什么,还要让埋点与字段尽量不因换观测后端而推倒重来。这一节把三件事收进一条工程链条:先固定可迁移遥测语义,再让离线评测与回归产出可进闸门的证据,最后在线上仍用同一套字段读 trace、成本、实验与用户反馈。底座语义与线上观测必须同源,否则灰度里对不上离线报表。

image.png

语义底座先把典型 span 名、属性键和事件形状写进约定,常见列包括 trace_idspan_idmodeltoken 进出与 cost_usd 等,并对齐 GenAIOpenTelemetry 社区里已经有人在用的写法。这样换导出器或换观测后端时,主要改连接与映射,业务代码少动字段名。离线评测与回归靠版本化用例集、对结果与格式与合规的断言、与基线的对照统计、接入 CI 的闸门和可计量的回归耗时,把主观手感压成可复跑的 Eval 分数。线上可观测在同一套定义下读 trace 时间线、成本随时间和用量变化、AB 流量拆分、用户情绪与满意线索、以及告警与异常。工程上的收束是:语义先沉淀进离线证据,离线结论再拿去和线上 trace、金丝雀或灰度放量对齐,团队才不会各写各的报表。

落到工具时,离线侧靠版本化用例、对过程与结果的断言、基线对比和接入 CI 的闸门把手感变成证据。promptfooDeepEvalRagas 分别偏配置、断言、指标,关键是同一套用例能从开发跑到发布。线上噪声更大,盯住任务完成率、工具成功与超时、成本与风险侧信号即可。LangfuseLangSmithPhoenixHelicone 选型看能否把 trace、实验、分数和反馈收进同一面板。OpenTelemetryGenAI 语义适合当公共约定,先统一 LLMtoolagent 如何建 span,以及 token、延迟、错误码等字段,迁移成本主要在导出器。

前端加 LangChain 开发者可以重点讲的几点

前端把运行中的系统摊开给人看:状态、步骤、工具、风险、中断重试入口,以及 token 与成本摘要。trace 不应只躺在仓库里,而要变成时间线、Step 卡片、风险高亮和失败回放。模型差不多时,把过程讲清楚往往比再换一次模型更能换来信任和效率。

一个成熟 Agent 项目的技术区分度该怎么描述

重点不是接了哪个新模型,而是能否在真实业务里持续跑稳、可对比、可审计。下面是一段自述示例,可按实际情况改名词和程度。

我做的不是调模型、调工具的 Demo,而是面向真实用户的 Agent 运行系统。LLM 与编排负责生成与流转,Harness、状态机 Loop、风险控制、HITL、可观测和评测负责稳定与可治理。 工程上我会打通离线评测、线上观测和回归闸门,用统一遥测语义串起 trace、成本、质量与用户反馈,让每次迭代可对比、可回放、可审计。 我有前端背景,会把过程可视化、干预入口和体验指标当成主交付物,而不是只交最后一段文本。

总结

RAG 可以做,Agent 也可以做,它们都只是手段,不是终点。真正拉开差距的是你有没有把需求、执行、观测、评测和迭代接成闭环。下面四条自检,有一半答不上来,就值得对照正文里的分层补一补。

  • 执行与韧性:是否有独立运行时与预算约束,Loop 是否显式状态机,故障能否回放,成本与步数是否可解释。
  • 质量与证据:是否有维护中的评测集、CI 或合并前的回归闸门,红队用例是否像测试代码一样可复跑,而不是发版前凭手感点几下。
  • 安全与过程:输入、工具调用前、输出与轨迹四层里,哪些已经落地成策略与埋点,高风险路径是否默认进审批而不是靠运气不触发。
  • 观测与闭环:线上是否能同时看到 trace、成本、实验与用户反馈,离线分数与线上信号能否进同一套界面或同一套数据模型,而不是各团队各一份报表。

能跑通链路只是起点,能不能长期闭环才是标准。

你有没有把它做成一个能稳定运行、可观测、可评测、可干预、还能持续迭代的闭环系统。

走前端加 LangChain 这条线的人,手里正好捏着界面、状态和事件,把这些和模型、工具、观测、评测缝在一起,比单纯多接一个模型更难被模板替代,写进自我介绍里也更有话可说。

昨天以前首页

面试官:LangChain中 TS 和 Python 版本有什么差别,什么时候选TS ❓❓❓

作者 Moment
2026年4月23日 09:23

大家好 👋,我是 Moment,目前正在使用 Next.js、NestJS、LangChain 开发 DocFlow。这是一个面向 AI 场景的协同文档平台,集成了基于 Tiptap 的富文本编辑、NestJS 后端服务、实时协作与智能化工作流等核心模块。

在这个项目的持续打磨过程中,我积累了不少实战经验,不只是 Tiptap 的深度定制、编辑器性能优化和协同方案设计,也包括前端工程化建设、React 源码理解以及复杂项目架构实践。

如果你对 AI 全栈开发、文档编辑器、前端工程化或者 React 源码相关内容感兴趣,欢迎添加我的微信 yunmz777 一起交流。觉得项目还不错的话,也欢迎给 DocFlow 点个 star ⭐

image.png

很多人一上来就问 LangChain.js 到底能不能和 Python 版打,其实这个问题放在 2025 年已经不太成立了。现在的 LangChain.js 和 Python 版早就不是一个能做、一个不能做的关系,而是生态重心、运行时环境、团队技能结构上的差别。官方两边都在围绕 createAgentcreate_agentmiddlewareLangGraph 这套 agent runtime 推进,核心能力的差距相比早期缩小了很多。真正值得讨论的是,在你自己的项目里到底该选哪一边。

核心能力已经很接近

先从能力层面看。两边都提供了生产可用的 agent 入口,Python 侧是 create_agent,JS/TS 侧是 createAgent。两边都把 middleware 作为核心定制机制,用来做上下文工程、摘要、PII 处理、人类审批、工具控制、状态管理这些事。JS 侧的 createAgent 底层基于 LangGraph 的 graph-based runtime,和 Python 侧走的是同一条架构路线。换句话说,做工具调用 agent、工作流 agent、带状态和中间控制的 agent,两边都能胜任,你不会因为选了 TS 就被卡在某个能力边界上。

生态厚度的差别在集成数量上

能力差不多,但生态厚度差得比较明显。官方集成页上 Python 侧的可用集成是 1000+,JS/TS 侧是 100 多个,差了将近一个数量级。

20260422172323

20260422172355

这个差距短期内不会被抹平,因为 Python 天然连着更大的 AI 和数据生态。数据清洗、文本处理、embedding 前处理、实验脚本、评测、离线任务,这些事情在 Python 里通常都有现成的库和现成的示例可以直接抄。你遇到一个冷门的向量库、冷门的模型提供商、冷门的文档解析器,在 Python 侧大概率能找到适配,在 JS 侧可能就要自己包一层。

反过来看,JS/TS 的优势不在集成数量上,而在离 Web 产品更近。如果你本来就在 Node 里做 API、SSE、WebSocket、前后端共享 schema、全栈 monorepo,那用 TS 会让系统边界更简单,省掉大量跨语言胶水代码。

TS 的真正价值是全栈一致

沿着上面这点继续往下说。选 TS 的最大好处其实不是 AI 能力更强,而是能把整个系统打通成一套类型。前端表单类型、后端 DTO、工具入参 schema、Zod 校验、agent 输出结构、SSE 返回类型,甚至日志事件类型,都可以放在同一套类型系统里管。这种一致性在 Python 和 TS 混用的架构里很难做到,常常要靠文档约定或者手写 schema 对齐,维护起来很累。

Node 项目里,middleware 这类运行时控制也更容易直接融进现有的 Web 服务,不用额外起一个 Python 子服务再做接口拼接。对做产品的团队来说,这种工程上的顺滑感,往往比多几百个集成更重要。

两边都更规范但仍在快速演进

说完优势再说一下稳定性。LangChain 目前按 semver 管理版本,minor 加新特性,patch 高频修 bug。和 0.x 时期相比稳定了很多,不再是动不动就改 API 的状态。但这个领域整体仍在快速变化,不管你选 Python 还是 TS,做生产项目都要锁版本,不要无脑追最新。这一点两边是一样的,不构成选型差异。

什么时候优先选 TS

把前面几点串起来看,就能得到一个比较清楚的选型判断。下面这几类场景选 TS 会更顺。

  • 在做 AI 产品,不是在做 AI 研究。比如聊天、文档编辑 agent、知识库问答、工作流编排、客服后台、内容生成后台这类偏产品交付的系统。
  • 主栈已经是 Next.jsNestJS 或者纯 Node,用 TS 能减少语言切换、减少服务拆分、减少跨语言 schema 漂移。
  • 特别在意类型安全和契约一致性,工具参数、结构化输出、前后端共享类型、Zod 校验这些需求都希望一套语言搞定。
  • 要把 AI 能力直接嵌进现有 Web 服务,比如 SSE 流式输出、实时 UI、在线编辑器、业务鉴权、BFF 层整合。

什么时候反而该选 Python

反过来,下面这几类场景选 Python 更省事。

  • 大量文档 ETL、离线索引、数据实验、批处理。
  • 高度依赖更广的第三方 AI、检索、数据生态,需要用到很多冷门集成。
  • 团队里 AI 工程师以 Python 为主,notebook 和实验迭代是主工作流。
  • 经常要找社区现成示例,希望命中率更高。

这两组判断背后的事实基础其实是同一个,就是 1000+ 和 100+ 这个集成数量差,决定了两边在不同场景下的顺手程度。

混合架构通常是更稳的落地方式

在真实项目里,很多团队不是非此即彼,而是两边都用。尤其是做 Next.jsNestJS 加编辑器 Agent 产品的团队,第一选择可以是 TS,但不代表全链路都得 TS。因为你真正要解决的问题不是做最前沿的算法实验,而是下面这几件事。

  • 怎么把 agent 接到产品里
  • 怎么和编辑器、接口、鉴权、队列、流式返回结合
  • 怎么把 schema、状态、工具调用、前后端契约统一起来

这些问题上 TS 比 Python 省很多系统复杂度。但一旦涉及重 ETL、重索引、重离线处理,用 TS 去硬啃生态空缺反而不划算。这时候比较实用的做法是把链路拆成两层。

20260422173256

前台产品层和在线 agent 层用 TS,负责直接面向用户的实时请求。重 ETL、重索引、重离线处理的 worker 单独上 Python,吃 Python 那边的生态红利。两条链路通过消息队列或者存储层解耦,互相不干扰。这种架构通常比一开始全 Python 或者强行全 TS 都更稳。

总结

回到最初那个问题。LangChain.js 和 Python 版今天已经站在同一条架构路线上,核心 agent 能力都够用,真正的差别在生态厚度和运行时环境。Python 胜在 1000+ 集成和更厚的 AI 数据生态,JS/TS 胜在和 Web 产品栈的天然贴合以及一套类型贯穿全栈的工程体验。

所以最后给你的结论是这样。做 Web 产品、编辑器、SaaS、Agent 平台这类偏产品交付的系统,优先 TS。做数据实验、检索管线、研究型系统这类偏数据和研究的工作,优先 Python。如果两头都要做,就按用户实时链路和离线数据链路拆开,让 TS 和 Python 各司其职。这样既能吃到 TS 在产品工程上的顺滑,也能吃到 Python 在 AI 生态上的厚度,不用二选一。

作为前端,如果使用 Langgraph 实现第一个 Agent

作者 Moment
2026年4月21日 10:37

大家好 👋,我是 Moment,目前正在使用 Next.js、NestJS、LangChain 开发 DocFlow。这是一个面向 AI 场景的协同文档平台,集成了基于 Tiptap 的富文本编辑、NestJS 后端服务、实时协作与智能化工作流等核心模块。

在这个项目的持续打磨过程中,我积累了不少实战经验,不只是 Tiptap 的深度定制、编辑器性能优化和协同方案设计,也包括前端工程化建设、React 源码理解以及复杂项目架构实践。

如果你对 AI 全栈开发、Agent、长期记忆、文档编辑器、前端工程化或者 React 源码相关内容感兴趣,欢迎添加我的微信 yunmz777 一起交流。觉得项目还不错的话,也欢迎给 DocFlow 点个 star ⭐

从这一篇开始,用一个简化版计算器 Agent 走一遍 LangGraph 的核心要素。目标很具体:只用节点、边、状态这三个概念,从零定义一张最小的图、让它真正跑起来,在代码里看清楚状态如何在节点之间流转。持久化、本地服务、子图等进阶内容都留到后面,这一章先让你对图式编排有可运行的手感。

用计算器 Agent 认识图

例子的场景是:用户用自然语言描述算式,比如"请帮我把三加四再乘二",模型理解后决定是否调工具,工具负责加减乘除等具体运算,结果回到模型整理成一句友好的回复。

这个场景不复杂,但很好地覆盖了图的三个关键能力:节点之间如何传递状态、条件边如何根据状态决定下一跳、工具节点执行完后如何回到模型节点继续推理。用图来表示整体执行流程,如下图所示。

20260317080940

模型节点与工具节点之间的回环,就是 LangGraphLangChain 线性链最本质的区别。下面按这个结构一步步把代码写出来。

准备模型与工具

图要跑起来,先得有一个支持工具调用的聊天模型,再配几个简单的计算工具。这部分仍然由 LangChain 提供,LangGraph 暂时不登场。

模型初始化时加了 temperature: 0,是为了让模型在判断"该不该调工具、该调哪个工具"这类结构化决策时输出更稳定,减少随机性带来的不必要波动。三个计算工具 addmultiplydividetool 函数定义,schemazod 写,这样模型拿到工具描述后能清楚知道每个参数的类型。最后调 model.bindTools(tools) 把工具列表注入模型,之后每次调用这个模型时,它就知道手边有哪些工具可用。

import { ChatOpenAI } from "@langchain/openai";
import { tool } from "@langchain/core/tools";
import * as z from "zod";

const model = new ChatOpenAI({
  model: "deepseek-chat",
  apiKey: "sk-60816d9be57f4189b658f1eaee52382e",
  configuration: { baseURL: "https://api.deepseek.com" },
  temperature: 0,
});

const add = tool(({ a, b }) => a + b, {
  name: "add",
  description: "Add two numbers",
  schema: z.object({
    a: z.number().describe("First number"),
    b: z.number().describe("Second number"),
  }),
});

const multiply = tool(({ a, b }) => a * b, {
  name: "multiply",
  description: "Multiply two numbers",
  schema: z.object({ a: z.number(), b: z.number() }),
});

const divide = tool(({ a, b }) => a / b, {
  name: "divide",
  description: "Divide two numbers",
  schema: z.object({ a: z.number(), b: z.number() }),
});

const toolsByName = {
  [add.name]: add,
  [multiply.name]: multiply,
  [divide.name]: divide,
};

const tools = Object.values(toolsByName);
const modelWithTools = model.bindTools(tools);

到这里模型和工具都准备好了,接下来才是 LangGraph 登场的地方。

定义图的状态

任何一张 LangGraph 图都需要一个状态模式,用来描述在节点之间流转的是哪些数据。状态不是普通对象,每个节点不是整体替换状态,而是只返回需要更新的字段,LangGraph 按字段的 reducer 把更新合并进去。

对于对话类应用,最常用的状态定义是 MessagesAnnotation,它内置了消息列表的 reducer 逻辑。节点每次返回 { messages: [newMessage] },状态系统就自动把这条消息追加到已有列表里,不需要手动维护整个消息数组。

import {
  StateGraph,
  MessagesAnnotation,
  START,
  END,
} from "@langchain/langgraph";

后面定义节点和组装图时都会用到 MessagesAnnotation,它既是状态模式的定义,也给 TypeScript 提供了节点函数参数的类型推断,写 state: typeof MessagesAnnotation.State 就能拿到完整的类型提示。

两个核心节点

这张图里只有两个真正干活的节点。llmCall 负责调用模型,根据当前 messages 生成一条新消息,并判断要不要请求工具。toolNode 根据上一轮模型的工具调用请求执行工具,把结果封装成 ToolMessage 返回。

节点函数可以只接收 state。如果需要流式或回调,可以声明第二个参数 config?: RunnableConfig,图运行时会自动传入。这样 invokestreamEvents 的回调就能一路传到模型和工具里,流式输出才能正常触发。

先写模型节点。它把系统提示和已有消息一起发给模型,只返回本次新生成的那条消息,状态系统负责追加。

import { SystemMessage } from "@langchain/core/messages";
import type { RunnableConfig } from "@langchain/core/runnables";

const llmCall = async (
  state: typeof MessagesAnnotation.State,
  config?: RunnableConfig
) => {
  const response = await modelWithTools.invoke(
    [
      new SystemMessage(
        "你是一个负责做算术的助手,根据用户描述执行加减乘除等运算,需要时调用工具得到结果后再用自然语言回复。"
      ),
      ...state.messages,
    ],
    config
  );
  return { messages: [response] };
};

再写工具节点。逻辑分三步:拿到最后一条 AIMessage,根据里面的 tool_calls 逐个执行对应工具,把每个工具的返回值包成 ToolMessage 追加到状态里。tool_call_id 是关键,模型后续要靠它把工具结果和当初的请求对应起来。

import { AIMessage, ToolMessage } from "@langchain/core/messages";

const toolNode = async (
  state: typeof MessagesAnnotation.State,
  config?: RunnableConfig
) => {
  const lastMessage = state.messages.at(-1);
  if (!lastMessage || !AIMessage.isInstance(lastMessage)) {
    return { messages: [] };
  }

  const results: ToolMessage[] = [];
  for (const toolCall of lastMessage.tool_calls ?? []) {
    const t = toolsByName[toolCall.name];
    if (!t) continue;
    const value = await t.invoke(toolCall.args ?? {}, config);
    results.push(
      new ToolMessage({
        content: String(value),
        tool_call_id: toolCall.id ?? "",
      })
    );
  }
  return { messages: results };
};

如果最后一条不是 AIMessage,或者 AIMessage 里没有工具调用,直接返回空列表,图会照常往下走,不会卡住。

条件边与路由

节点准备好之后,还要告诉图跑完某个节点之后下一步去哪。这里的逻辑很清楚:模型回来的消息里如果带着 tool_calls,说明它想用工具,就走到 toolNode;如果没有 tool_calls,说明模型已经可以直接给用户回复了,图结束。

const shouldContinue = (state: typeof MessagesAnnotation.State) => {
  const lastMessage = state.messages.at(-1);
  if (!lastMessage || !AIMessage.isInstance(lastMessage)) return END;
  if (lastMessage.tool_calls?.length) return "toolNode";
  return END;
};

这个函数返回的是字符串(节点名)或 ENDLangGraph 拿到返回值后就知道下一步跳到哪个节点。条件边是 LangGraph 表达"分支逻辑"的核心机制,比把 if/else 藏在节点函数里要清晰得多,图的结构一眼就能看懂。

组装并运行整张图

把状态、节点和边用 StateGraph 链式调用串在一起,最后调 compile() 得到可执行的图。addConditionalEdges 的第三个参数是允许到达的节点列表,LangGraph 会在编译时验证条件边函数的返回值不会跳到意外的节点,起到一定的安全检查作用。

import { HumanMessage } from "@langchain/core/messages";

const agent = new StateGraph(MessagesAnnotation)
  .addNode("llmCall", llmCall)
  .addNode("toolNode", toolNode)
  .addEdge(START, "llmCall")
  .addConditionalEdges("llmCall", shouldContinue, ["toolNode", END])
  .addEdge("toolNode", "llmCall")
  .compile();

const result = await agent.invoke({
  messages: [new HumanMessage("请帮我算一下 3 加 4 等于多少。")],
});

for (const message of result.messages) {
  const content = typeof message.content === "string" ? message.content : "";
  console.log(message.getType(), content);
}

以"请帮我算一下 3 加 4 等于多少"为例,图的完整执行路径如下:

  1. START 进入 llmCall,模型判断需要调 add 工具,返回带 tool_callsAIMessage
  2. shouldContinue 检测到有工具调用,走到 toolNode
  3. toolNode 执行 add(3, 4) 得到 7,包成 ToolMessage 追加到状态,沿固定边回到 llmCall
  4. 模型拿到工具结果,生成"3 加 4 等于 7"这样的自然语言回复,这次没有工具调用,shouldContinue 返回 END,图结束

走完这四步,messages 列表里依次记录了用户消息、模型的工具请求、工具的执行结果、模型的最终回复,完整还原了整条推理过程。

流式输出

invoke 是一次性拿到全部结果,适合脚本和批处理。如果要做"边生成边显示"的体验,用 agent.streamEvents 按事件消费。

import { ChatOpenAI } from "@langchain/openai";
import { tool, type StructuredTool } from "@langchain/core/tools";
import { SystemMessage, AIMessage, ToolMessage, HumanMessage } from "@langchain/core/messages";
import type { RunnableConfig } from "@langchain/core/runnables";
import { StateGraph, MessagesAnnotation, START, END } from "@langchain/langgraph";
import * as z from "zod";

// 模型
const model = new ChatOpenAI({
  model: "deepseek-chat",
  apiKey: "sk-60816d9be57f4189b658f1eaee52382e",
  configuration: { baseURL: "https://api.deepseek.com" },
  temperature: 0,
});

// 工具
const add = tool(({ a, b }) => String(a + b), {
  name: "add",
  description: "Add two numbers",
  schema: z.object({ a: z.number(), b: z.number() }),
});

const multiply = tool(({ a, b }) => String(a * b), {
  name: "multiply",
  description: "Multiply two numbers",
  schema: z.object({ a: z.number(), b: z.number() }),
});

const divide = tool(({ a, b }) => String(a / b), {
  name: "divide",
  description: "Divide two numbers",
  schema: z.object({ a: z.number(), b: z.number() }),
});

const toolsByName: Record<string, StructuredTool> = {
  add,
  multiply,
  divide,
};

const modelWithTools = model.bindTools(Object.values(toolsByName));

// 节点
const llmCall = async (
  state: typeof MessagesAnnotation.State,
  config?: RunnableConfig
) => {
  const response = await modelWithTools.invoke(
    [
      new SystemMessage("你是一个负责做算术的助手,根据用户描述执行加减乘除等运算,需要时调用工具得到结果后再用自然语言回复。"),
      ...state.messages,
    ],
    config
  );
  return { messages: [response] };
};

const toolNode = async (
  state: typeof MessagesAnnotation.State,
  config?: RunnableConfig
) => {
  const lastMessage = state.messages.at(-1);
  if (!lastMessage || !AIMessage.isInstance(lastMessage)) return { messages: [] };

  const results: ToolMessage[] = [];
  for (const toolCall of lastMessage.tool_calls ?? []) {
    const t = toolsByName[toolCall.name];
    if (!t) continue;
    const value = await t.invoke(toolCall.args ?? {}, config);
    results.push(new ToolMessage({ content: String(value), tool_call_id: toolCall.id ?? "" }));
  }
  return { messages: results };
};

// 条件路由
const shouldContinue = (state: typeof MessagesAnnotation.State) => {
  const lastMessage = state.messages.at(-1);
  if (!lastMessage || !AIMessage.isInstance(lastMessage)) return END;
  return lastMessage.tool_calls?.length ? "toolNode" : END;
};

// 组装图
const agent = new StateGraph(MessagesAnnotation)
  .addNode("llmCall", llmCall)
  .addNode("toolNode", toolNode)
  .addEdge(START, "llmCall")
  .addConditionalEdges("llmCall", shouldContinue, ["toolNode", END])
  .addEdge("toolNode", "llmCall")
  .compile();

// 流式运行
async function main() {
  const stream = agent.streamEvents(
    { messages: [new HumanMessage("请帮我算一下 3 加 4 等于多少。")] },
    { version: "v2" }
  );

  for await (const event of stream) {
    if (event.event === "on_chat_model_stream") {
      const chunk = event.data?.chunk?.content;
      if (typeof chunk === "string" && chunk) process.stdout.write(chunk);
    }
    if (event.event === "on_tool_start") {
      console.log(`\n[工具调用] ${event.name}`, JSON.stringify(event.data?.input));
    }
    if (event.event === "on_tool_end") {
      console.log(`[工具结果] ${event.name}: ${event.data?.output}`);
    }
  }

  console.log("\n");
}

main().catch(console.error);

on_chat_model_stream 是模型逐 token 输出,从 event.data.chunk.content 取片段写到终端就能得到打字机效果。on_tool_starton_tool_end 分别在工具开始和结束时触发,可以用来显示"正在计算……"这样的进度提示。这些事件能正常触发的前提是节点里把 config 传给了 modelWithTools.invoket.invoke,回调通路才算打通。如果节点没有传 config,这些事件就不会冒出来。

如果只想按节点观察每一步的状态增量,不关心逐字,可以换成 agent.streamstreamMode: "updates",每个 chunk 就是"节点名 -> 该节点本次返回的状态更新",调试时很有用。

和 LangChain 传统写法的对比

LangChain 写过类似计算器 Agent 的话,会发现两者的逻辑其实差不多,都是模型判断是否需要工具、调用工具、再根据工具结果生成回复。但有几个点上 LangGraph 的优势很明显。

流程可见方面,用 LangChainAgentExecutor,整条执行链路藏在对象内部,从外部很难直接看出走了哪些步骤。用 LangGraph 写,节点、边、条件路由全都显式定义,图的结构就是代码本身,不需要额外文档解释流程。

状态可追方面,LangGraph 图执行过程中,每个节点的输入输出都是状态的一次快照。后面加上 checkpointer 之后,这些快照可以持久化,支持暂停恢复、时间旅行和回放,AgentExecutor 做不到这一点。

扩展性方面,这一章的图很小,只有两个节点。后面加入持久化、人机协同、子图、多 Agent 协作时,只需要在图里加节点和边,不需要重写整个逻辑,扩展起来很自然。

小结

这一章用计算器 Agent 完整走了一遍 LangGraph 的核心三要素,几个值得记住的点:

  • MessagesAnnotation 定义消息列表状态,每个节点只返回增量,框架负责合并,不用手动维护整个数组
  • llmCall 节点负责调用模型,toolNode 节点负责执行工具,两者通过条件边构成一个可以反复循环的 Agent 推理回路
  • shouldContinue 是整张图的路由核心,tool_calls 有值走工具、为空走 END,分支逻辑和节点实现彻底分开
  • streamEvents 打通了逐 token 流式输出和工具事件,前提是节点里把 config 一路传下去,回调通路才能正常工作
  • addConditionalEdges 的第三个参数声明了合法的目标节点,图在编译时会做边界检查,防止条件函数返回意外节点名

下一章会在这张图上引入 checkpointer,给每次执行打快照,为持久化、暂停恢复和时间旅行做准备。

❌
❌