阅读视图

发现新文章,点击刷新页面。

老司机 iOS 周报 #367 | 2026-03-30

ios-weekly
老司机 iOS 周报,只为你呈现有价值的信息。

你也可以为这个项目出一份力,如果发现有价值的信息、文章、工具等可以到 Issues 里提给我们,我们会尽快处理。记得写上推荐的理由哦。有建议和意见也欢迎到 Issues 提出。

文章

🐕 用好你的 jj - 重新思考 Agent 时代的版本控制

@阿权:文章是 onevcat(喵神)安利 jj(Jujutsu) 在 AI Agent 时代替代 Git 进行本地版本控制。jj 是可与 Git 无缝兼容的本地版本控制工具(兼容方式为远端仍是 Git 提交),核心优势在于以 change 为核心,无 staging area 等中间态,操作直观,完美适配 AI Agent “先生成后整理”的工作模式,无需开发者打断业务思考指挥版本操作,比 Git 更适合 Agent 参与的本地开发。

🐎 Copy-On-Write in Swift: Semantics, Misconceptions, and a Custom Implementation

@Barney:这篇文章把 Swift 中的 Copy-on-Write 讲得很清楚,重点不是重复 “值类型修改时会复制” 这类结论,而是澄清 COW 只是某些类型选择采用的实现策略,并不是所有 struct 天生自带的机制。作者先从“值类型外壳 + 引用类型存储”的经典结构切入,说明标准库集合为什么能同时兼顾值语义和复制成本;再结合 isKnownUniquelyReferenced(_:) 展示写入前如何判断底层存储是否需要分离,并用一个自定义 SharedBuffer 例子串起完整实现。后半部分还补充了 _read / _modify accessor 在减少额外复制上的作用,以及自定义 COW 真正值得引入的场景:数据量大、复制频繁、读多写少且又希望保留值语义。对需要设计高性能数据结构的同学,这是一篇兼顾原理和落地实现的好文章。

🐎 OpenMAIC

@JonyFang:OpenMAIC(Open Multi-Agent Interactive Classroom)是清华开源的 AI 互动课堂平台,能够将任意主题或文档一键转化为沉浸式学习体验。核心亮点包括:多智能体协作(AI 老师 + AI 同学实时授课讨论)、丰富场景类型(幻灯片、测验、HTML 交互模拟、项目制学习)、白板语音实时讲解,以及 OpenClaw 集成支持在飞书、Slack、Telegram 等 20+ 聊天应用中直接生成课堂。项目支持 Vercel 一键部署和 Docker 本地运行,兼容主流 LLM 服务商,开箱即用。

🐕 Array expression trailing closures in Swift

@Smallfly:这篇文章深入解析了 SE-0508 提案带来的 Swift 语法改进,解决了数组与字典类型长期存在的尾随闭包使用限制,让语言一致性与 API 设计灵活性得到显著提升。核心亮点包括:

历史痛点解决:此前 Swift 解析器因 [T][K:V] 的语法歧义,禁止在数组 / 字典类型表达式后使用尾随闭包,导致自定义初始化器(如 builder 风格、@resultBuilder API)必须使用 .initArray<T> 形式,破坏代码简洁性。SE-0508 移除该限制,允许 [String] { ... } 这类符合直觉的语法。

API 设计赋能:库作者现在可以为数组 / 字典设计更自然的 DSL 风格 API,比如基于 @resultBuilder 的集合初始化器、流式生成数组的构造函数,语法与自定义类型保持统一,降低开发者学习成本。

扩展交互能力:支持与 callAsFunction 特性结合,实现数组字面量后直接接闭包的转换操作(如 ["a","b"] { $0.uppercased() }),进一步提升代码表达力。

语言一致性提升:消除了集合类型与自定义类型在尾随闭包语法上的差异,让 Swift 语言的语法规则更统一,同时仅存在极窄的兼容性影响,整体是小而美的语法优化。

这个提案虽然没有引入新的 runtime 特性,但通过平滑语法边缘,为开发者带来更符合直觉的编码体验,尤其对依赖闭包初始化的集合 API 场景帮助显著。

🐎 Xcode 26 Compilation Cache

@david-clang:Xcode 26 Compilation Cache 的根本目标不仅是让编译器提速 5%,而是彻底停止重复已完成的工作。相比缺乏复用能力的 DerivedData,新机制在输入源未变时会直接提取缓存。这在切换分支、清理重建及高频 CI 场景下,能免去大量无谓的编译损耗。当然,若项目真正的瓶颈在于资源处理或繁杂的脚本,它也并非一劳永逸的银弹。

🐎 Testing with Event Streams

@AidenRao:这篇文章讲的是把一批“靠回调驱动的异步测试”从 XCTest 迁移到 Swift Testing 时,如何既验证回调是否发生,又验证发生顺序。作者对比了 XCTestExpectation、Swift Testing 的 confirmation(容易写成层层嵌套且难区分顺序),最终给出一个很实用的解法:用 AsyncStream 把回调事件“汇总成事件流”,再在测试里收集并断言事件序列,顺手还封装了一个小型 EventStream wrapper 来减少样板代码。

工具

App-Store-Connect-CLI

asc-cli 是一款强大的 App Store Connect 命令行工具。相比于 Fastlane 庞大的体系,它更加聚焦且现代。直接调用 Apple 官方的 App Store Connect API,提供简洁的命令来处理从 Beta 邀请到内购项创建的所有杂活。

最重要的是,它不需要你懂 Ruby,没有复杂的环境配置。 对于追求极致简洁、想在终端或 CI 环境中快速调动 App Store 能力的开发者来说,这是一款足以取代 Fastlane 大部分功能的利器。

代码

🐕 MotionEyes

@Cooper Chen:MotionEyes 是一个面向 AI Agent 的 SwiftUI 动画可观测性工具,它将原本“只能靠肉眼判断”的 UI 动画行为,转化为可量化、可分析的结构化数据。通过在应用中插入轻量级 tracing(如位置、几何、滚动等),开发者可以实时记录动画过程,并以时间序列日志形式还原真实运动轨迹。

项目的亮点在于其“ agent-first ”设计:不仅提供底层埋点能力,还配套自动化调试 workflow 和视觉分析工具,能够生成关键帧、网格标注、像素差异等结果,帮助精确定位动画异常。

相比传统调试方式,MotionEyes 更像一个“动画黑盒分析仪”,适用于排查错位、卡顿、时序错误等复杂 UI 问题。对于构建高质量交互动效或探索 AI 辅助开发流程的团队来说,这是一个非常有前瞻性的基础设施工具。

内推

重新开始更新「iOS 靠谱内推专题」,整理了最近明确在招人的岗位,供大家参考

具体信息请移步:https://www.yuque.com/iosalliance/article/bhutav 进行查看(如有招聘需求请联系 iTDriverr)

关注我们

我们是「老司机技术周报」,一个持续追求精品 iOS 内容的技术公众号,欢迎关注。

关注有礼,关注【老司机技术周报】,回复「2024」,领取 2024 及往年内参

同时也支持了 RSS 订阅:https://github.com/SwiftOldDriver/iOS-Weekly/releases.atom

说明

🚧 表示需某工具,🌟 表示编辑推荐

预计阅读时间:🐎 很快就能读完(1 - 10 mins);🐕 中等 (10 - 20 mins);🐢 慢(20+ mins)

16.98 万元起!丰田这台「拼好车」铂智 7,把华为、Momenta、小米都装上了

丰田可能是一众合资车企里,把「打不过就加入」执行得最彻底的一家。

在铂智 7 的上市发布会上,他们提出了一个口号:「新能源时代的丰田味道」。

即他们的策略是,作为一家最擅长系统整合的车企,丰田继续守住整车制造、底盘调校、安全验证这些核心护城河;至于自身积累相对薄弱的座舱、智驾等环节,则深度引入华为、Momenta 等科技公司的能力,加快成熟方案落地。

所以,铂智 7 也确实称得上是一台集多方优势于一身的「拼好车」。

「我全都要」

先来看看价格配置。

铂智 7 共推出 6 个版本, 指导价区间为 16.98 万元至 22.98 万元,整个版本体系的配置递进比较清晰。

入门版 600 Pro 已经标配鸿蒙座舱、数字钥匙、纯天然实木内饰、座椅通风加热、20 寸轮毂等基础配置;600 Pro 激光雷达版则进一步引入 Momenta R6 强化学习大模型,支持高速领航、城区领航、记忆泊车等功能。

700 系列全系标配双电机、零重力座椅、主驾大腿托、后排智能触控面板和 23 扬声器。顶配 710 Ultra 激光雷达版,在三大核心配置全部到位的基础上,又增加了 27 英寸全景 HUD 抬头显示、 NAPPA 真皮方向盘与座椅,以及智能香氛系统。

能看出来,丰田这次的配置逻辑并不复杂,日常高频使用、对驾乘体验影响最直接的功能,尽量做到全系覆盖;差异化配置,则在这个基础上按版本逐步上探。

铂智 7 的车身尺寸为 5130/1965/1500mm,轴距 3020mm,是广汽丰田目前在售纯电轿车中车宽最大的一款。

放在同级合资纯电车型里看,很多产品的轴距还停留在 2.8 米左右。铂智 7 做到 3020 mm 的轴距、接近 2 米的车宽,后排空间和横向宽适感自然更容易做出来。

设计上,新车前脸采用封闭式极简布局,视觉重心压得很低;分体式大灯搭配 C 型贯穿灯带,车顶激光雷达也没有刻意隐藏。车身侧面通过溜背曲线收束,配合 21 英寸十辐轮毂和 255 / 40 R21 轮胎,整体姿态比较低趴。尾部则用熏黑包围和贯穿式尾灯收尾,风格相对利落。

不过,相比设计,铂智 7 这次真正更值得聊的,还是它在智能化上的「彻底开放」和「深度共创」。

新车搭载华为鸿蒙座舱 5,完成了从「功能机」到「智能机」的一次跃迁。它支持 50 + 鸿蒙应用和 5 万 + 手机应用的无感流转,同时也把 AI Agent 「智能体」真正带进了更具体的车内场景。

按照广汽丰田的说法,新车引入了 MoLA 通用大模型,并在导航、车控、多媒体等垂直领域部署专用智能体。

比如,导航智能体支持多轮复杂指令修改和模糊语义理解;车控智能体则允许用户自定义「智慧场景」。

这种「能动口就不动手」的交互方式,至少在体验层面,缩小了它和新势力之间的差距,也让丰田第一次更接近一种「可对话」的产品形态。

广汽丰田此次还宣布与小米达成合作,开放硬件生态接口。这意味着未来车载屏幕、音响等设备将有机会直接接入小米庞大的智能生态,实现车机与智能家居的无缝互联。

在中国市场尤其看重的智驾层面,铂智 7 搭载 Momenta R6 强化学习大模型,并配备激光雷达在内的 27 个高精度传感器。

但丰田的做法并不是简单买一套现成方案装上车。它真正的差异化在于,依然用丰田 TSS 智行安全系统的标准,去约束和校准本土智驾算法的工程化落地。

这套系统在开发过程中,全面植入了 14 项丰田安全标准,重点针对暴雨、夜间、鬼探头、行人横穿等长尾复杂场景做了更深的优化和冗余设计。

「丰田味道」

当然,丰田也没有丢掉自己一直强调的机械素质。

动力部分,铂智 7 采用华为 DriveONE 双电机电驱系统,最大功率 207 kW,最高车速设定为 180 km / h。电池则提供 88.13 kWh 和 71.35 kWh 两种规格。

其中,88.13 kWh 电池组根据配置不同,提供 680 km、700 km 和 710 km 三种续航版本;71.35 kWh 电池组对应的纯电续航里程为 600 km。

针对此前对电池品牌的质疑,丰田此次也特别做了回应。

丰田不唯品牌知名度论,而是综合考量安全、品质、购车成本等关键因素,为用户提供更均衡的方案。

具体来看,广汽丰田采用了「育成式采购」模式。工程师会提前 2 年反向进驻电池厂,围绕铂智 7 的电池提出超过 300 项安全标准,并验证 26 道关键工序,尽量确保每一个零部件、每一道工序都达到丰田全球标准。

基于这套体系,广汽丰田也给出了明确承诺:电池衰减在 2 年或 5 万公里内超过 10 %、6 年或 10 万公里内超过 20 %、8 年或 15 万公里内超过 25 %,厂家直接担责,并可免费更换全新原厂电池。

底盘方面,铂智 7 全系标配双腔空气弹簧与 SDC 减振器,并由丰田 GR(Gazoo Racing)与雷克萨斯团队参与调校。

这套悬架系统可根据路况实时调节阻尼软硬,在颠簸路段变软滤震,在高速过弯时变硬提供支撑,将此前多用于高端车型的底盘配置下放至 20 万级市场。

与这套高规格硬件相匹配的,是丰田近乎苛刻的耐久性测试标准。

新车的空悬系统经历了 904 万次测试,远超行业普遍的 450 万次标准;悬架耐久验证也达到了 20 万次。

铂智 7 还提供双腔空悬超长质保。在常规 5 年 / 15 万公里基础上叠加延保权益后,质保期限可达 10 年或 30 万公里,大约是行业平均水平的 2 倍。

安全性设计上,铂智 7 整车高强度钢和铝合金占比达到 73 %,最高强度达到 2 GPa。

同时,丰田把很多安全冗余前置到了设计阶段。比如,它把传动轴断裂点预设在电驱壳体内部,避免碰撞时部件侵入乘员舱;又采用机电一体式门把手设计,确保在极端断电情况下,车门依然可以从外部物理开启。

最懂车的全球大厂加最懂科技的中国伙伴,就是铂智 7 的核心竞争力。

广汽丰田在铂智 7 上的思路,其实可以概括成两条线同时推进。

一条线面向外部,主动整合华为和 Momenta 在座舱交互、高效电驱、智驾算法上的成熟能力,借助本土智能供应链的优势加快研发节奏,也更贴近中国用户的需求。

另一条线面向内部,继续把整车制造、底盘调校、安全验证这些核心环节抓在自己手里,用丰田体系的工程标准去约束和校准外部方案的落地质量。

这两条线配合起来,产品能跟上时代,品质和可靠性也还有基本盘。

这才是「新能源时代的丰田味道」真正想表达的东西,该借力的地方不硬撑,该自己扛的地方也绝不放手。

这种既「把产品做对」,又能「把产品做好」的策略,或许才是合资品牌下一阶段竞争中更为可行的路径。

#欢迎关注爱范儿官方微信公众号:爱范儿(微信号:ifanr),更多精彩内容第一时间为您奉上。

科氪 | 变革传统同传设备,亮亮视野推出全球首创AR+AI会议翻译系统,部署中关村论坛

在2026中关村论坛主会场,亮亮视野联合智谱AI推出并部署了全球首创的AR+AI会议翻译系统,为主论坛及多场平行论坛提供大会翻译保障。这套系统深度融合亮亮视野AR眼镜与智谱AI大模型能力,可支持54种语言实时翻译,延迟小于1秒,单次续航8小时,并可7×24小时稳定运行。这标志着国际会议翻译从传统设备和人工依赖,迈向更高效率、更低成本、更强普惠性的 AR+AI 新阶段。

长期以来,国际会议翻译领域始终受困于部署繁琐、成本高昂、语种覆盖有限、实时传递滞后等核心痛点。尤其在大型论坛、专业峰会等高端跨语言交流场景中,传统同传模式更受限于包厢搭建、接收设备调试、专业人工译员稀缺及会场信号不稳定等多重因素,既大幅增加活动组织方的筹备压力,也难以保障信息传递的准确性与一致性,制约跨语言沟通的效率与质量。

外籍嘉宾佩戴亮亮视野AR翻译眼镜接受媒体采访

自2025年起,亮亮视野聚焦国际会议翻译领域的效率升级,持续深耕更具可行性的创新解决方案,先后为IASP世界大会、北京文化论坛、中国高层发展论坛等一系列高规格国际盛会,提供专业AR翻译服务。本次与智谱AI联合打造的这套翻译系统,正是精准破解行业核心痛点,在大幅压缩部署成本的同时,显著提升翻译效率与使用体验的突破性创新解法。

据介绍,该系统通过AR眼镜直接呈现实时字幕,参会者无需额外耳机和复杂配对,即可佩戴即用、开机4秒即进入工作状态。系统最高支持上万台设备统一接入与管理,适用于百人、千人级乃至更大规模的国际会议场景。与此同时,系统通过麦克风直传与高精度拾音技术,尽可能消除远距离收音带来的衰减,确保会场前后排获取到一致、清晰的信息,显著提升了大型会议的信息平权能力。

在翻译能力上,该系统将大模型翻译深度引入会议翻译场景,不仅支持54种语言实时转换,还可预录专业术语、嘉宾名单及专有名词,并具备动态纠错能力,进一步提升专业表达的准确度与稳定性。对于科技、外事、产业等高专业度场景而言,这意味着系统不再只是“翻译工具”,而是面向复杂国际沟通场景的智能基础设施。

《连线》杂志创始主编凯文·凯利体验亮亮视野AR翻译眼镜

亮亮视野创始人兼 CEO 吴斐表示,此次 AR+AI 会议翻译系统于中关村论坛主会场的正式部署,不仅实现了全球范围内技术路径与产品模式的双重首创,更有望重新定义全球国际会议翻译服务的行业标准范式。从先行先试的创新土壤到领跑全球的科创高地,北京之所以能持续坐稳全球科技创新中心的核心席位,离不开北京市政府对本土创新的包容托举、敢闯敢试的决心与实打实的推进力度。

 

未来,亮亮视野将与智谱 AI 持续深化协同,深耕 AR 显示、实时翻译与大模型能力的深度融合创新,推动国际会议、公共服务、专业交流等多元场景实现沟通效率与体验的双重升级,打造更高效、更包容、更智能的无障碍沟通生态,以自主科技创新为锚,以开放合作为桥,助力全球深度交流与世界文明互鉴。

“AI未来论坛”在京举办,红杉中国、高瓴创投等6家投资机构集中签约

36氪获悉,3月29日,2026中关村论坛年会“人工智能主题日”特色专题论坛“AI未来论坛:跃迁·投资·共生”成功举办。活动现场,中关村科学城国际创新服务集聚区迎来重要时刻,红杉中国、高瓴创投、经纬创投、五源资本、金沙江创投、真知创投6家投资机构作为投资生态伙伴集中签约。集聚区是海淀区为系统性集聚全球顶尖创投与科创服务资源而建,旨在打造全球前沿科技的创投中心,构建“人才-技术-场景-资本”高效协同生态,最大化加速创新。

从“连接失败”到丝滑登录:我用 ethers.js 连接 MetaMask 的完整踩坑实录

背景

上个月,我接手了一个新的 NFT 铸造平台前端开发。项目要求很简单:用户进入网站,点击“连接钱包”按钮,用 MetaMask 登录,然后页面显示其钱包地址和 ETH 余额。这听起来是 Web3 开发的“Hello World”,对吧?我心想,用老伙计 ethers.js 分分钟搞定。毕竟之前参与 DeFi 项目时也用过,感觉轻车熟路。于是,我新建了一个 React 项目,安装好 ethers,开始撸代码。没想到,就是这个看似简单的任务,让我在接下来的几个小时里,跟各种奇怪的错误和浏览器行为斗智斗勇。

问题分析

我最开始的思路非常直接:检查 window.ethereum 是否存在(这是 MetaMask 注入的对象),然后用 ethers.providers.Web3Provider 包装它,最后调用 provider.send('eth_requestAccounts') 来请求账户授权。代码一气呵成,运行,点击按钮——控制台一片寂静,页面毫无反应。

我第一反应是 MetaMask 没安装?检查了一下,扩展明明好好的。然后我加了一堆 console.log,发现 window.ethereum 确实是存在的。那问题出在哪?我仔细阅读了 ethers.js 文档,发现了一个关键点:MetaMask 从 v8 开始,window.ethereum 的 API 发生了变化,它现在是一个 EIP-1193 规范的 Provider,而 ethers.jsWeb3Provider 正是为了适配这种规范而设计的。我的思路没错啊。

接着,我尝试在按钮点击事件里直接写:

const accounts = await window.ethereum.request({ method: 'eth_requestAccounts' });

这次弹窗出来了!这说明基础连接请求是通的。那么问题就锁定在 ethers.js 的用法上了。我意识到,我可能忽略了异步状态和 React 生命周期的配合,以及一些错误处理的边界情况。是时候重新梳理,一步步构建一个健壮的连接流程了。

核心实现

第一步:检测 Provider 与浏览器兼容性

首先,我们不能假设用户一定安装了 MetaMask。因此,连接之前必须先做检测。同时,现代 MetaMask 也可能同时注入 window.ethereum 和旧的 window.web3,我们应该优先使用新的。

// 检测函数
const checkIfMetaMaskInstalled = () => {
  // 检查是否有 EIP-1193 规范的 provider
  if (window.ethereum && window.ethereum.isMetaMask) {
    return true;
  }
  // 如果用户使用的是非常老的版本,可能只有 window.web3
  if (window.web3 && window.web3.currentProvider) {
    console.warn('检测到旧版 MetaMask,建议用户升级。');
    // 这里可以做一些降级处理,但为了简单,我们先返回false引导用户
    return false;
  }
  return false;
};

这里有个坑:仅仅检查 window.ethereum 是不够的,因为其他钱包(如 Coinbase Wallet)也可能注入这个对象。所以加上 window.ethereum.isMetaMask 属性判断更准确。但注意,这个属性是 MetaMask 特有的。

第二步:初始化 Ethers Provider 和 Signer

检测通过后,我们需要初始化 ethers 的核心对象:Provider 和 Signer。Provider 是连接区块链的抽象,Signer 代表一个有签名权限的账户。

import { ethers } from 'ethers';

const initializeProviderAndSigner = async () => {
  // 再次确认,避免竞态条件
  if (!window.ethereum) {
    throw new Error('请安装 MetaMask!');
  }

  // 1. 创建 Web3Provider
  // 注意:ethers v5 和 v6 的导入方式不同,这里是 v5
  const provider = new ethers.providers.Web3Provider(window.ethereum, 'any'); // 'any' 表示支持任何网络

  // 2. 请求账户授权,这会弹出 MetaMask 窗口
  await provider.send('eth_requestAccounts', []);

  // 3. 获取 Signer
  const signer = provider.getSigner();

  // 4. 获取当前账户地址
  const address = await signer.getAddress();

  return { provider, signer, address };
};

注意这个细节new ethers.providers.Web3Provider(window.ethereum, ‘any’) 中的第二个参数 ‘any’。这是网络配置,‘any’ 允许任何网络。如果你只支持特定网络(如主网),可以传入 ‘homestead’。使用 ‘any’ 能让用户在切换网络(比如从以太坊主网切换到 Polygon)时,我们的 provider 能自动适应,而不会报错。

第三步:监听账户与网络变化

用户可能在连接后切换 MetaMask 账户,或者切换网络。如果我们的前端没有监听这些事件,状态就会不同步,导致显示错误的地址或余额。

const setupEventListeners = (provider: ethers.providers.Web3Provider, updateAccountCallback: (address: string) => void) => {
  // 监听 accountsChanged 事件(用户切换账户)
  window.ethereum.on('accountsChanged', (accounts: string[]) => {
    if (accounts.length === 0) {
      // 用户锁定了钱包或断开了所有账户
      console.log('请连接钱包');
      updateAccountCallback('');
    } else {
      // 账户切换了
      console.log('当前账户变为:', accounts[0]);
      updateAccountCallback(accounts[0]);
      // 注意:这里不需要再次请求授权(eth_requestAccounts)
    }
  });

  // 监听 chainChanged 事件(用户切换网络)
  window.ethereum.on('chainChanged', (_chainId: string) => {
    // 链ID是十六进制字符串,例如“0x1”(主网)
    console.log('网络切换,新的Chain ID:', _chainId);
    // 页面完全重载是最简单的方式,因为很多合约实例、provider都需要重新初始化
    window.location.reload();
  });
};

这里有个大坑chainChanged 事件触发后,简单的更新状态可能不够。因为网络变了,之前初始化的 Provider 实例内部可能还缓存着旧网络的 RPC 信息,直接使用可能导致后续的 RPC 调用发往错误的网络。最稳妥的办法是刷新页面,让所有组件重新初始化。虽然体验略有中断,但能保证状态绝对干净。在更复杂的 DApp 中,你可能需要设计一个更精细的状态管理方案来优雅地处理网络切换。

第四步:获取账户余额并整合到 React 状态

最后,我们把上面的功能整合到一个 React 组件中,并获取账户的 ETH 余额。

import { useState, useEffect, useCallback } from 'react';

const useMetaMask = () => {
  const [account, setAccount] = useState<string>('');
  const [balance, setBalance] = useState<string>('');
  const [isConnecting, setIsConnecting] = useState<boolean>(false);
  const [error, setError] = useState<string>('');

  const connectWallet = useCallback(async () => {
    setIsConnecting(true);
    setError('');
    try {
      if (!checkIfMetaMaskInstalled()) {
        throw new Error('未检测到 MetaMask,请安装后重试。');
      }

      const { provider, signer, address } = await initializeProviderAndSigner();
      setAccount(address);

      // 获取余额
      const balanceRaw = await provider.getBalance(address);
      const balanceFormatted = ethers.utils.formatEther(balanceRaw);
      setBalance(balanceFormatted);

      // 设置事件监听
      setupEventListeners(provider, (newAddress) => {
        setAccount(newAddress);
        if (newAddress) {
          // 如果切换到了新账户,重新获取余额
          provider.getBalance(newAddress).then(bal => setBalance(ethers.utils.formatEther(bal)));
        } else {
          setBalance('');
        }
      });

    } catch (err: any) {
      console.error('连接钱包失败:', err);
      setError(err.message || '连接失败');
      setAccount('');
      setBalance('');
    } finally {
      setIsConnecting(false);
    }
  }, []); // 依赖项为空,因为这个函数只在初始化时定义一次

  // 组件卸载时,移除事件监听(避免内存泄漏)
  useEffect(() => {
    return () => {
      if (window.ethereum && window.ethereum.removeListener) {
        // 注意:ethers provider 包装后,事件源还是 window.ethereum
        window.ethereum.removeAllListeners('accountsChanged');
        window.ethereum.removeAllListeners('chainChanged');
      }
    };
  }, []);

  return { account, balance, isConnecting, error, connectWallet };
};

注意这个细节:获取的余额是 BigNumber 类型,单位是 wei(1 ETH = 10^18 wei)。必须用 ethers.utils.formatEther 将其转换为可读的 ETH 单位字符串。另外,错误处理非常重要,要把 MetaMask 抛出的错误(比如用户拒绝连接)友好地展示给用户。

完整代码

下面是一个可以直接在 React 项目中使用的完整组件示例:

// MetaMaskConnector.tsx
import React, { useState, useEffect, useCallback } from 'react';
import { ethers } from 'ethers';

// 类型声明
declare global {
  interface Window {
    ethereum?: any;
    web3?: any;
  }
}

const MetaMaskConnector: React.FC = () => {
  const [account, setAccount] = useState<string>('');
  const [balance, setBalance] = useState<string>('');
  const [isConnecting, setIsConnecting] = useState<boolean>(false);
  const [error, setError] = useState<string>('');
  const [provider, setProvider] = useState<ethers.providers.Web3Provider | null>(null);

  // 1. 检测 MetaMask
  const checkIfMetaMaskInstalled = useCallback((): boolean => {
    return !!(window.ethereum && window.ethereum.isMetaMask);
  }, []);

  // 2. 初始化
  const initializeWallet = useCallback(async () => {
    if (!window.ethereum) throw new Error('未安装 MetaMask');

    const prov = new ethers.providers.Web3Provider(window.ethereum, 'any');
    await prov.send('eth_requestAccounts', []);
    const signer = prov.getSigner();
    const address = await signer.getAddress();

    return { prov, address };
  }, []);

  // 3. 连接钱包主函数
  const connectWallet = useCallback(async () => {
    setIsConnecting(true);
    setError('');
    try {
      if (!checkIfMetaMaskInstalled()) {
        throw new Error('请安装 MetaMask 浏览器扩展。');
      }

      const { prov, address } = await initializeWallet();
      setProvider(prov);
      setAccount(address);

      // 获取余额
      const balanceRaw = await prov.getBalance(address);
      setBalance(ethers.utils.formatEther(balanceRaw));

    } catch (err: any) {
      console.error('连接失败:', err);
      setError(err.message || '未知错误');
      setAccount('');
      setBalance('');
    } finally {
      setIsConnecting(false);
    }
  }, [checkIfMetaMaskInstalled, initializeWallet]);

  // 4. 设置事件监听
  useEffect(() => {
    if (!provider || !window.ethereum) return;

    const handleAccountsChanged = (accounts: string[]) => {
      if (accounts.length === 0) {
        // 用户锁定了钱包
        setAccount('');
        setBalance('');
        setError('钱包已断开连接。');
      } else if (accounts[0] !== account) {
        // 切换了账户
        setAccount(accounts[0]);
        provider.getBalance(accounts[0]).then(bal => {
          setBalance(ethers.utils.formatEther(bal));
        });
      }
    };

    const handleChainChanged = () => {
      // 网络切换,建议刷新页面
      window.location.reload();
    };

    window.ethereum.on('accountsChanged', handleAccountsChanged);
    window.ethereum.on('chainChanged', handleChainChanged);

    // 清理函数
    return () => {
      if (window.ethereum && window.ethereum.removeListener) {
        window.ethereum.removeListener('accountsChanged', handleAccountsChanged);
        window.ethereum.removeListener('chainChanged', handleChainChanged);
      }
    };
  }, [provider, account]);

  // 5. 页面加载时尝试自动连接(可选,谨慎使用)
  useEffect(() => {
    const tryAutoConnect = async () => {
      if (checkIfMetaMaskInstalled() && window.ethereum.isConnected()) {
        // 检查是否已经授权过
        const accounts = await window.ethereum.request({ method: 'eth_accounts' });
        if (accounts.length > 0) {
          // 自动连接
          connectWallet();
        }
      }
    };
    tryAutoConnect();
  }, [checkIfMetaMaskInstalled, connectWallet]);

  return (
    <div style={{ padding: '20px', fontFamily: 'sans-serif' }}>
      <h2>MetaMask 钱包连接示例</h2>
      {!account ? (
        <div>
          <button
            onClick={connectWallet}
            disabled={isConnecting}
            style={{
              padding: '10px 20px',
              fontSize: '16px',
              cursor: isConnecting ? 'wait' : 'pointer',
            }}
          >
            {isConnecting ? '连接中...' : '连接 MetaMask'}
          </button>
          {error && <p style={{ color: 'red' }}>错误: {error}</p>}
        </div>
      ) : (
        <div>
          <p><strong>连接成功!</strong></p>
          <p><strong>账户地址:</strong> {`${account.substring(0, 6)}...${account.substring(account.length - 4)}`}</p>
          <p><strong>ETH 余额:</strong> {parseFloat(balance).toFixed(4)} ETH</p>
          <button
            onClick={() => {
              setAccount('');
              setBalance('');
              setError('');
            }}
            style={{ marginTop: '10px', padding: '5px 10px' }}
          >
            断开连接(前端)
          </button>
          <p style={{ fontSize: '12px', color: '#666' }}>
            (注意:这只是前端清除状态,MetaMask 中的连接授权仍需在其界面内管理)
          </p>
        </div>
      )}
      {!checkIfMetaMaskInstalled() && (
        <p style={{ color: 'orange', marginTop: '10px' }}>
          未检测到 MetaMask。请
          <a href="https://metamask.io/download/" target="_blank" rel="noopener noreferrer">下载安装</a>
          后刷新页面。
        </p>
      )}
    </div>
  );
};

export default MetaMaskConnector;

踩坑记录

  1. window.ethereumundefined,但 MetaMask 已安装。

    • 问题:在 Next.js 或 SSR 框架中,代码可能在服务器端执行,那里没有 window 对象。
    • 解决:所有对 window.ethereum 的访问都必须放在 useEffect 中或通过 typeof window !== ‘undefined’ 进行保护。
  2. 用户拒绝连接后,再次点击按钮无效。

    • 问题:MetaMask 会记住用户的拒绝操作,短时间内再次调用 eth_requestAccounts 不会弹出窗口。
    • 解决:引导用户点击 MetaMask 扩展图标,在弹出界面中手动重置已拒绝的站点授权。这是 MetaMask 的用户体验设计,前端无法绕过。
  3. accountsChanged 事件在初次连接时也触发了。

    • 问题:有些版本的 MetaMask 在用户授权账户后,会立即触发一次 accountsChanged 事件,导致事件处理函数和初始化逻辑重复执行。
    • 解决:在事件处理函数中,通过对比新旧账户地址来判断是初次连接还是主动切换。如果旧地址为空字符串,新地址有值,可以视为初次连接的一部分,避免不必要的状态更新或重复请求。
  4. 余额显示为巨大的整数。

    • 问题:直接 console.logprovider.getBalance() 获取的结果,显示为一个包含 hex 属性的对象或一个巨大的数字。
    • 解决:这是 ethers.jsBigNumber 类型。必须使用 ethers.utils.formatEther 进行单位转换。我差点自己写转换函数,幸好查了文档。

小结

通过这次实践,我深刻体会到 Web3 前端开发中“细节决定成败”——Provider的初始化参数、事件监听的绑定与清理、异步错误处理,每一个环节疏忽都可能导致功能失效。完整的钱包连接不仅仅是弹出授权窗口,更要考虑用户后续的所有操作路径。下一步,我可以在此基础上集成合约调用、签名消息等功能,并考虑用 wagmi 这样的高阶库来管理更复杂的状态。

中信证券:配置上建议继续坚守中国优势制造业,静待4月决断

36氪获悉,中信证券研报认为,短期资本市场仍然处于情绪降温期,损失规避的心态可能产生一些减仓需求。在配置上,建议继续坚守中国优势制造业,静待4月决断。目前的底仓建议仍然是中国有份额优势、海外产能重置成本高难度大、且供应弹性容易被政策影响的行业,以化工、有色、电力设备和新能源为基础。在以上底仓的基础上,建议继续增加对低估值因子的敞口暴露,重点关注保险、券商和电力。从短期景气信号驱动的框架来考虑,涨价仍然是最“锋利的矛”,PPI交易成为全年主线的概率在上升,4—5月就是决断期。

Pretext:一个绕过 DOM 的纯 JavaScript 排版引擎

本文首发 猩猩程序员 欢迎关注

在 Web 前端开发中,"文本到底有多高"这个看似简单的问题,一直是困扰开发者的一大难题。传统做法是将文本塞入 DOM,然后通过 getBoundingClientRectoffsetHeight 等 API 来获取尺寸,但这种操作会触发浏览器的布局回流(layout reflow) ——这是浏览器中最昂贵的操作之一。现在,由 Cheng Lou 开源的 Pretext 库,用一种全新的方式彻底解决了这个问题。

Cheng Lou 是前端领域的重量级人物,曾参与 ReactJS、ReasonML、ReScript、Messenger 以及 Midjourney 等知名项目的开发。他在 2026 年 3 月底正式发布了 Pretext,一经发布便在社区引发广泛关注。

Pretext 是什么?

Pretext 是一个纯 JavaScript/TypeScript 编写的多行文本测量与排版库。它的核心理念是:完全绕过 DOM,用纯算术运算完成文本高度计算与行排版。 它借助浏览器自身的字体引擎(通过 Canvas 的 measureText)作为真实度量来源,实现了自己的文本测量逻辑。换句话说,Pretext 做了一次前期准备(prepare),之后的所有布局计算都是极其廉价的纯数学运算,无需再次触碰 DOM。

更令人印象深刻的是,Pretext 支持几乎所有你能想到的语言和文字系统,包括 emoji 和混合双向文本(Bidi),并且针对不同浏览器的排版差异进行了专门处理。

为什么需要 Pretext?

Web 开发中,文本高度的获取是许多高级 UI 功能的基石,但传统方法的代价太高。Pretext 的出现为以下场景提供了全新的解决方案。

虚拟列表与遮挡剔除(Virtualization/Occlusion)。 在构建长列表或无限滚动界面时,开发者需要知道每个条目的高度以正确计算滚动位置。过去,这通常依赖粗略的估算或缓存值。Pretext 能以极低的成本精确计算文本高度,让虚拟列表的实现变得可靠且高效。

自定义布局引擎。 想在 JavaScript 中实现瀑布流(Masonry)布局、类 Flexbox 布局,或是对布局值进行微调而不依赖 CSS hack?Pretext 提供的精确文本尺寸信息,正是这些自定义布局所需的关键输入。

开发时验证。 特别是在 AI 辅助生成 UI 的今天,开发者可以在不打开浏览器的情况下验证按钮上的文案是否会溢出到下一行。

消除布局偏移(Layout Shift)。 当新文本加载后需要重新锚定滚动位置时,Pretext 可以提前计算高度,从而避免恼人的页面跳动。

核心 API 设计

Pretext 的 API 设计非常精巧,分为两个主要使用场景。

场景一:快速测量段落高度

这是最常见的使用方式。只需两步:先用 prepare() 做一次性的文本分析和度量,然后用 layout() 进行纯算术计算。

import { prepare, layout } from '@chenglou/pretext'

const prepared = prepare('AGI .   ', '16px Inter')
const { height, lineCount } = layout(prepared, textWidth, 20)

prepare() 负责繁重的前期工作:规范化空白字符、分词分段、应用断行规则,以及通过 Canvas 度量各段的宽度,最终返回一个不透明的句柄。layout() 则是高频热路径——只做纯数学运算,无任何 DOM 交互。

在官方的基准测试中,对 500 段文本的批处理中,prepare() 耗时约 19ms,而 layout() 仅需约 0.09ms。这意味着在需要反复重排布局的场景(如窗口缩放、响应式调整)中,性能优势极为显著。

场景二:手动逐行排版

对于更高级的渲染需求,Pretext 提供了一套丰富的底层 API,支持渲染到 Canvas、SVG、WebGL 等非 DOM 目标。

layoutWithLines() 可以在固定宽度下返回所有行的详细信息:

import { prepareWithSegments, layoutWithLines } from '@chenglou/pretext'

const prepared = prepareWithSegments('AGI .   ', '18px "Helvetica Neue"')
const { lines } = layoutWithLines(prepared, 320, 26)
for (let i = 0; i < lines.length; i++) ctx.fillText(lines[i].text, 0, i * 26)

walkLineRanges() 是一个低级 API,可以在不构建文本字符串的情况下遍历行宽和游标信息,非常适合做投机性布局探索——例如通过二分搜索找到一个"恰到好处"的容器宽度,实现 CSS 原生无法做到的多行文本"收缩包裹(shrink-wrap)"效果。

let maxW = 0
walkLineRanges(prepared, 320, line => { if (line.width > maxW) maxW = line.width })
// maxW 就是最宽行的宽度——也就是能容纳文本的最窄容器宽度!

layoutNextLine() 则提供了迭代器风格的逐行排版能力,允许每一行使用不同的宽度。这对于文本环绕浮动元素的场景尤其有用:

let cursor = { segmentIndex: 0, graphemeIndex: 0 }
let y = 0

while (true) {
  const width = y < image.bottom ? columnWidth - image.width : columnWidth
  const line = layoutNextLine(prepared, cursor, width)
  if (line === null) break
  ctx.fillText(line.text, 0, y)
  cursor = line.end
  y += 26
}

pre-wrap 模式

Pretext 还支持 pre-wrap 白空间模式,适用于类似 <textarea> 的场景,保留普通空格、\t 制表符和 \n 硬换行符。只需在 prepare()prepareWithSegments() 中传入 { whiteSpace: 'pre-wrap' } 选项即可。制表符遵循浏览器默认的 tab-size: 8 行为。

当前限制

Pretext 目前并不试图成为一个完整的字体渲染引擎,而是聚焦于最常见的文本排版配置:white-space: normal(或 pre-wrap)、word-break: normaloverflow-wrap: break-wordline-break: auto。在 macOS 上,使用 system-ui 字体可能影响 layout() 的精度,建议使用具名字体。此外,由于默认包含 overflow-wrap: break-word,在极窄的容器中,单词可能会在字素(grapheme)边界处被断开。

设计哲学与技术渊源

Pretext 的核心架构可以追溯到 Sebastian Markbage 在上一个十年创建的 text-layout 项目。其中利用 Canvas measureText 进行字形度量、借鉴 pdf.js 的双向文本处理、以及流式断行算法等设计思想,都被 Pretext 继承和进一步完善。

值得注意的是,Pretext 的迭代方法被描述为"非常 AI 友好"——这意味着开发者可以很方便地让 AI 工具利用 Pretext 的 API 来构建和验证复杂的文本布局。

对 Web 开发的意义

Pretext 的发布标志着 Web 文本排版领域的一个重要里程碑。长期以来,Web 开发者不得不将布局决策完全交给浏览器的 CSS 引擎,而 Pretext 打开了一扇新的大门——让 JavaScript 拥有了独立于 DOM 的、精确的文本排版能力。

这对于构建下一代 Web UI 具有深远影响。无论是 AI 驱动的动态界面生成、高性能虚拟滚动列表、跨渲染目标(Canvas/SVG/WebGL/服务端)的统一文本排版,还是 CSS 原生无法实现的"收缩包裹"与"平衡文本"布局,Pretext 都提供了坚实的基础。

快速上手

安装非常简单:

npm install @chenglou/pretext

想要体验更多 Demo,可以访问 chenglou.me/pretext 或克隆仓库后运行 bun install && bun start 在本地查看。

总结下吧

Pretext 用一种优雅而务实的方式,解决了 Web 开发中一个由来已久的痛点。它不是要取代浏览器的排版引擎,而是为开发者提供了一个轻量、快速、精确的补充工具,让文本测量不再是性能瓶颈,让布局创新不再受限于 DOM 的束缚。对于任何需要精确控制文本排版的前端项目来说,Pretext 都值得关注和尝试。

本文首发 猩猩程序员 欢迎关注

广汽丰田铂智7正式上市

36氪获悉,3月29日,丰田首款鸿蒙座舱豪华旗舰大型纯电轿车铂智7正式上市,新车共推出600km和700km两种续航5款配置,限时补贴权益价14.78万-19.98万元。

三安光电:董事长及总经理拟增持公司股份

36氪获悉,三安光电公告,公司董事长林志强和副董事长、总经理林科闯拟自3月31日起的6个月内,通过上海证券交易所系统以集中竞价方式增持公司股份,林志强拟增持金额2000万元—4000万元,林科闯拟增持金额500万元—1000万元。

科大讯飞AIPC华东制造基地正式投产,年规划产能10万台

36氪获悉,近日,由科大讯飞主办的“信创AIPC华东制造基地(金华)投产暨首机下线仪式”在浙江举行。科大讯飞规划年产能达10万台的国产AIPC产线正式步入规模化量产阶段。据了解,该基地深度集成了ERP、WMS、MES等七大核心数字化平台,实现了从零部件入库到成品出库的全流程闭环管控。仪式现场,科大讯飞与龙芯中科正式达成战略合作。

北京启动智能网联新能源汽车商业保险开发应用

记者今天(29日)从北京金融监管局了解到,北京已在全国率先启动智能网联新能源汽车商业保险产品开发应用。据介绍,近年来随着智能网联汽车产业发展迅速,智能驾驶等前沿技术广泛应用,改变了交通事故风险因素、场景及损失形态等,现有商业机动车辆保险难以精准适配智能网联汽车独有的特定致损原因、使用场景和特有的软硬件设备损失等情况。为适应产业发展和消费者的新需求,北京金融监管局在金融监管总局的指导和北京市委市政府的支持下,以先行先试的方式,在全国率先开发应用专属产品。(央视新闻)

融捷股份:近期公司经营情况及内外部经营环境未发生重大变化

36氪获悉,融捷股份发布股票交易异常波动公告称,公司股票交易价格连续两个交易日内(2026年3月26日和3月27日)日收盘价格涨幅偏离值累计超过20%,属于股票交易异常波动的情况。公司未发现近期公共传媒报道了可能或已经对公司股票交易价格产生较大影响的未公开重大信息。近期公司经营情况及内外部经营环境未发生重大变化。

百邦科技:筹划公司控制权变更事项,30日起停牌

36氪获悉,百邦科技公告,公司近日收到控股股东达安世纪、悦华众城及实际控制人刘铁峰的通知,公司控股股东、实际控制人正在筹划公司控制权变更相关事宜,该事项可能导致公司控股股东、实际控制人发生变更。经公司向深圳证券交易所申请,公司股票自3月30日(星期一)开市起停牌,预计停牌时间不超过2个交易日。

上海二手房单日成交1585套创近5年新高,业内人士:市场在明显回暖

安居客上海监测数据显示,3月28日(周六),上海二手房(含商业)单日网签成交量达1585套,不仅刷新今年年内单日成交纪录,更是一举打破2025年3月15日1473套的前峰值纪录,创下近5年上海二手房单日网签成交历史新高。中原地产市场分析师卢文曦认为,当前这一成交量,已经能说明市场在明显回暖。在其看来,现在的热度,更多是刚需主导的底部修复信号,市场进一步恢复还需要更多时间。(澎湃)

Ctrl ACV工程师的提效之路:删掉项目中的冗余

在前端项目的开发周期中,随着功能的迭代、人员的更替,项目中会逐渐积累大量的冗余文件和未被使用的依赖包。这些“累赘”不仅会占用存储空间,还会拖慢项目的构建速度、增加维护成本,甚至可能引发潜在的兼容性问题。因此,定期对前端项目进行冗余清理,是保障项目稳定高效运行的必要举措。

为什么会产生冗余?

(一)依赖包的“历史遗留”

在前端项目开发过程中,为了快速实现某些功能,开发者通常会引入各种各样的第三方依赖包,新建项目时可能是从老项目复制过来的,有很多冗余依赖,然而,随着项目的推进,部分功能可能被废弃、重构或者替换,但是对应的依赖包却没有及时被移除。

(二)文件的“迭代残留”

在项目的功能迭代过程中,开发者会不断创建新的文件来实现新的功能,同时也会对旧的文件进行修改、重构。然而,很多时候,旧的文件并没有被及时删除,而是被保留在项目中,成为“历史遗留文件”。这些文件可能是早期的草稿版本、被废弃的组件、测试用的临时文件等。

【例子】从index.vue文件复制了一份index Copy.vue,结果没用上;另外就是有些开发者喜欢复制index_old.vue去记录老版本代码,实际上过了一段时间的版本迭代之后,新代码已经稳定运行,老代码还原回来也会报错,变得毫无意义。

(三)配置文件的“过时无效”

前端项目中通常包含大量的配置文件,如package.json、webpack.config.js、.babelrc等。随着项目技术栈的升级、构建工具的更新,这些配置文件中的一些配置项可能会变得过时或者无效。例如,当Webpack从4.x版本升级到5.x版本时,一些旧的配置项如optimization.splitChunks的默认值发生了变化,旧的配置可能不再适用,但开发者可能没有及时更新配置文件,导致配置文件中存在冗余的配置项。此外,一些配置文件中可能还残留着针对已废弃功能的配置,这些配置不仅没有实际作用,还会增加配置文件的复杂度。

(四)团队协作中的“沟通盲区”

在多人协作的前端项目中,由于沟通不畅或者信息不对称,也容易导致冗余的产生。例如,当一个开发者负责开发某个功能模块时,可能会创建一些临时文件或者引入一些依赖包,但是在功能完成后,没有及时告知其他团队成员,也没有将这些文件或依赖从项目中移除。而其他团队成员在不知情的情况下,可能会继续基于这些冗余内容进行开发,导致冗余进一步积累。此外,不同开发者的开发习惯和规范不一致,也可能导致项目中出现重复的文件或依赖。

冗余清理的方法与工具

删除依赖:depcheck

执行之后,会列出这几个依赖列表,例如:

Unused dependencies:

  • lodash
    Unused devDependencies:
  • eslint-plugin-vue@6.2.2
    Missing dependencies:
  • axios

删除多余的文件

借助AI工具(Trae),直接帮我们生成了一个Node.js脚本,输出未被引用的文件:

const fs = require('fs');
const path = require('path');

// Recursively get all files with specified extensions
function getAllFiles(dir, extensions) {
  let files = [];
  const items = fs.readdirSync(dir, { withFileTypes: true });
  
  for (const item of items) {
    const itemPath = path.join(dir, item.name);
    if (item.isDirectory()) {
      files = [...files, ...getAllFiles(itemPath, extensions)];
    } else if (extensions.includes(path.extname(item.name))) {
      files.push(itemPath);
    }
  }
  
  return files;
}

function analyzeUnusedFiles() {
  const srcDir = '/my-pro/src';
  const projectRoot = '/my-pro';
  
  // Get all TypeScript, Vue, and JavaScript files
  const extensions = ['.ts', '.vue', '.js', '.tsx', '.jsx'];
  const absoluteFiles = getAllFiles(srcDir, extensions);
  
  const importRegex = /from\s+['"]([^'"]+)['"]/g;
  const referencedFiles = new Set();
  
  // Add main.ts as referenced since it's imported in index.html
  const mainTsPath = path.join(srcDir, 'main.ts');
  referencedFiles.add(mainTsPath);
  
  // Check index.html for references to main.ts
  const indexHtmlPath = path.join(projectRoot, 'index.html');
  if (fs.existsSync(indexHtmlPath)) {
    const indexContent = fs.readFileSync(indexHtmlPath, 'utf8');
    if (indexContent.includes('/src/main.ts')) {
      referencedFiles.add(mainTsPath);
    }
  }
  
  // Track all referenced files
  for (const file of absoluteFiles) {
    if (fs.existsSync(file)) {
      const content = fs.readFileSync(file, 'utf8');
      let match;
      
      // Check for import statements
      while ((match = importRegex.exec(content)) !== null) {
        let importedPath = match[1];
        
        // Handle alias imports
        if (importedPath.startsWith('/@/')) {
          importedPath = importedPath.replace('/@/', '');
          importedPath = path.join(srcDir, importedPath);
        } 
        // Handle relative imports
        else if (importedPath.startsWith('./') || importedPath.startsWith('../')) {
          importedPath = path.resolve(path.dirname(file), importedPath);
        }
        
        // Add common extensions if missing
        if (!path.extname(importedPath)) {
          const exts = ['.ts', '.vue', '.js', '.tsx', '.jsx'];
          for (const ext of exts) {
            const candidate = importedPath + ext;
            if (fs.existsSync(candidate)) {
              importedPath = candidate;
              break;
            }
          }
        }
        
        // Add to referenced files if it exists
        if (fs.existsSync(importedPath)) {
          referencedFiles.add(importedPath);
        }
      }
      
      // Check for dynamic imports
      const dynamicImportRegex = /import\(['"]([^'"]+)['"]\)/g;
      let dynamicMatch;
      while ((dynamicMatch = dynamicImportRegex.exec(content)) !== null) {
        let importedPath = dynamicMatch[1];
        
        // Handle alias imports
        if (importedPath.startsWith('/@/')) {
          importedPath = importedPath.replace('/@/', '');
          importedPath = path.join(srcDir, importedPath);
        } 
        // Handle relative imports
        else if (importedPath.startsWith('./') || importedPath.startsWith('../')) {
          importedPath = path.resolve(path.dirname(file), importedPath);
        }
        
        // Add common extensions if missing
        if (!path.extname(importedPath)) {
          const exts = ['.ts', '.vue', '.js', '.tsx', '.jsx'];
          for (const ext of exts) {
            const candidate = importedPath + ext;
            if (fs.existsSync(candidate)) {
              importedPath = candidate;
              break;
            }
          }
        }
        
        // Add to referenced files if it exists
        if (fs.existsSync(importedPath)) {
          referencedFiles.add(importedPath);
        }
      }
    }
  }
  
  // Find unreferenced files
  const unreferencedFiles = absoluteFiles.filter(file => !referencedFiles.has(file));
  
  console.log('未被引用的文件:');
  unreferencedFiles.forEach(file => console.log(file.replace(srcDir, '')));
}

analyzeUnusedFiles();

输出结果:

204bb29a-730b-4711-b293-bdf774378f1e.png

94640c74-a2cc-46b6-8f8a-481f7c4bfbb3.png

AI给我们的思路是:使用脚本递归扫描项目文件,检测import语句和动态import,识别被引用的文件。

删除空文件夹

分支切换的时候经常会出现,我们也可以通过执行脚本去删除对应文件夹。

const rimraf = require('rimraf');
const fs = require('fs');
const path = require('path'); 

// 递归遍历目录,找出所有空目录
function findEmptyDirs(dir) {  
  const emptyDirs = [];  
  const items = fs.readdirSync(dir);  
  for (const item of items) {    
    const itemPath = path.join(dir, item);    
    const stats = fs.statSync(itemPath);    
    if (stats.isDirectory()) {      
      const subItems = fs.readdirSync(itemPath);      
      if (subItems.length === 0) {        
        emptyDirs.push(itemPath);      
      } else {        
        emptyDirs.push(...findEmptyDirs(itemPath));      
      }    
    }
  }  
  return emptyDirs;
} 

// 删除所有空目录
function deleteEmptyDirs(rootDir) {  
  const emptyDirs = findEmptyDirs(rootDir);  
  console.log(`找到 ${emptyDirs.length} 个空目录`);   
  for (const dir of emptyDirs) {    
    rimraf(dir, (err) => {      
      if (err) {        
        console.error(`删除失败: ${dir}`, err);      
      } else {        
        console.log(`已删除: ${dir}`);      
      }    
    });
  }
} 

// 示例调用

deleteEmptyDirs('./src'); 

如果空文件夹是有用的,可以在空文件夹里新建一个.gitkeep文件用于占位。

清理后的验证与总结

  1. 依靠AI,但不依赖

在上面用AI生成脚本检索代码的时候,我发现AI帮我把package.json完全替换了,显然这不是我们所期望的。我们通过AI找到了冗余文件,不能直接让AI帮忙删掉,而是自己去再做一次复核,然后再去删除,毕竟项目负责人是我们自己,不是AI。我们不能和用户说:由于AI误删除了文件导致了项目无法运行。

  1. 性能指标评估

清理完成后,我们应该对项目的性能指标进行评估,对比清理前后的差异,衡量清理的效果。主要关注的性能指标包括:项目的构建时间、打包后的文件大小、页面的加载时间等等。

  1. 清理文档记录

最后,要对本次冗余清理的过程和结果进行详细的文档记录。记录的内容包括:清理的时间、清理的范围、使用的工具、移除的依赖包列表、删除的文件列表、遇到的问题及解决方法、性能指标的对比结果等。

  1. 对冗余代码进行分析

清理了一些代码之后,我们需要去分析冗余产生的原因,从而去规范化我们的代码。例如,静态图片文件要统一放在指定的目录下;相似组件库,例如vue-draggablesortable.js只取一个即可。

五、结语

前端项目的冗余清理是一项长期而细致的工作,它不仅仅是简单地删除几个文件或依赖包,更是对项目技术债务的一次梳理和优化。通过定期的冗余清理,我们可以为前端项目减轻负担,提高项目的构建速度和运行性能,降低维护成本,同时也能让项目的代码结构更加清晰、易于理解。在清理过程中,我们也要注意对现有业务不能有影响,需要自己验证,并且备份原始代码,在出问题的时候可以一键回退。

LangChain 进阶实战:从玩具 Demo 到生产级 AI 应用(JS/TS 全栈版)

前端 er 零门槛上手,从核心原理、LCEL 黑魔法、RAG 优化到生产避坑,一篇给你讲透!

家人们谁懂啊!2026 年了,还有人觉得 LangChain 就是个「拼 LLM 接口的胶水框架」?

我见过太多同学,跟着教程 npm install 一下,写了个调用 DeepSeek 的 Demo,输出一句「你好,我是 AI」,就发朋友圈说自己入门 AI 开发了。结果产品经理一句「给我做个能查公司内部文档的客服机器人」,直接傻眼:

  • RAG 检索永远答非所问,上下文驴唇不对马嘴
  • 多轮对话聊个七八轮就崩,token 直接爆仓
  • 想换个性价比更高的模型,代码要全量重写
  • 线上一限流、API 一超时,服务直接原地升天

最后只能甩锅「LangChain 不好用」—— 不是它不好用,是你只解锁了它 10% 的能力!

上一篇我们聊了 LangChain 的核心概念和基础 Demo,这篇咱们直接进阶,用前端 er 听得懂的梗、能直接抄的生产级代码,把 LangChain 扒得明明白白,带你从「调包侠」直接进阶成 AI 应用架构师。

一、重新认识 LangChain:你之前对它的理解可能全错了

很多人对 LangChain 的认知还停留在「Lang = 语言模型,Chain = 把步骤串起来」,格局小了家人们!咱们先把底层逻辑掰扯清楚。

1.1 它不是胶水代码,是 AI 应用界的「React」

咱们前端 er 都懂:原生 JS 能写页面,但是为什么大家都用 React?因为 React 把 DOM 操作、状态管理、组件复用、生命周期这些脏活累活全给你封装好了,让你能专注写业务逻辑,不用天天跟浏览器兼容性对线。

LangChain 就是干了一模一样的事!

  • 原生 LLM 接口 = 原生 JS:能实现基础功能,但是每加一个需求就要写一堆重复代码,换个环境直接不兼容
  • LangChain = React:把提示词工程、模型适配、数据流转、工具调用、内存管理、异常处理这些 AI 应用的通用脏活全给你封装了,提供了一套标准化的开发范式,让你不用天天跟不同模型的 API 文档对线。

它的核心从来不是「把接口串起来」,而是一套可组合、可扩展、生产级可用的 AI 原生应用开发框架

1.2 前端 er 狂喜!JS/TS 生态才是全栈开发的王炸

很多人有个误区:「LangChain 是 Python 的,JS 版本就是个玩具」。大错特错!

现在 LangChain 的 JS/TS 版本已经完全成熟,生产级可用,而且对咱们前端全栈开发者来说,它简直是天选之子:

  • 完全基于 Node.js 开发,原生支持 ESM 规范,跟你天天写的 Next.js、NestJS、Express 无缝衔接,不用额外学一门 Python
  • 完美兼容前端生态,你熟悉的 npm、yarn、pnpm 直接用,dotenv、axios 这些常用库随便接
  • 类型提示拉满!TypeScript 原生支持,写代码的时候 IDE 直接给你提示参数、报错,不用对着文档瞎猜,这一点直接吊打 Python 版本的体验。

别再被 Python 教程劝退了,用你最熟悉的 JS/TS,照样能写出顶级的 AI 应用。

1.3 适配器模式的终极奥义:不止是换模型,是给应用上了「双保险」

上一篇我们聊了 LangChain 的适配器模式,很多同学看完只记住了「能随便换模型」,但它的价值远不止于此。

咱们用前端最熟悉的 Axios 来类比:Axios 为什么好用?因为它封装了浏览器和 Node.js 的 http 请求差异,不管你在什么环境,都是一套get/postAPI,不用管底层是 XMLHttpRequest 还是 http 模块。

LangChain 的适配器就是干了这件事!

  • 不管你用的是 DeepSeek、OpenAI、Anthropic,还是本地部署的 Ollama 模型,全都是一套标准的invoke/stream接口
  • 不用去研究每个模型的请求格式、参数差异、鉴权方式,适配器全给你处理好了
  • 你写的业务逻辑完全和底层模型解耦,真正做到了「面向接口编程,而不是面向实现编程」

这带来的生产级价值,可比「换模型方便」大多了:

  1. 模型 A/B 测试:同一个业务逻辑,同时测试 DeepSeek 和 GPT-4o 的效果,只需要改一行模型名,业务代码一行不用动
  2. 灰度发布:新模型上线,先给 10% 的流量用,出问题一键切回旧模型,零成本
  3. 降级容灾:主模型 API 挂了、限流了,自动切换到备用模型,服务完全不中断,用户根本感知不到
  4. 成本优化:简单问题用便宜的小模型,复杂问题用强大的大模型,自动切换,把你的 API 账单直接打下来

二、LangChain 灵魂核心:LCEL 表达式,告别祖传屎山代码

如果你还在用旧版的ConversationChainRetrievalQA这些类式 API,那你真的错过了 LangChain 最香的部分 ——LCEL(LangChain Expression Language,LangChain 表达式语言)

LCEL 就是 LangChain 的「React Hooks」,直接告别了旧时代类式 API 的冗余、难复用、难维护的问题,用声明式的管道写法,让你写 AI 应用跟写 Promise 链一样简单。

2.1 什么是 LCEL?5 分钟上手,比写 Promise 链还简单

LCEL 的核心就是一个管道符|,把不同的功能模块像管道一样串起来,前一个模块的输出,就是后一个模块的输入。

咱们用前端的概念类比:它就像 RxJS 的pipe方法,或者数组的链式调用,把数据处理的每一步拆成独立的函数,可组合、可复用、可测试。

先看个最简单的例子,对比一下旧写法和 LCEL 写法的差距:

旧版类式 API 写法(又臭又长,难维护)

javascript

import 'dotenv/config';
import { ChatDeepSeek } from '@langchain/deepseek';
import { PromptTemplate } from '@langchain/core/prompts';
import { LLMChain } from 'langchain/chains';

// 初始化模型
const model = new ChatDeepSeek({
  model: 'deepseek-reasoner',
  temperature: 0.7,
});

// 创建提示词模板
const prompt = PromptTemplate.fromTemplate(`
  你是一个{role},请用不超过{limit}个字符回答:{question}
`);

// 创建Chain
const chain = new LLMChain({ llm: model, prompt: prompt });

// 调用Chain
const res = await chain.call({
  role: '前端架构师',
  limit: 50,
  question: '怎么快速学好React?'
});

console.log(res.text);

LCEL 写法(简洁优雅,一行搞定核心逻辑)

javascript

import 'dotenv/config';
import { ChatDeepSeek } from '@langchain/deepseek';
import { ChatPromptTemplate } from '@langchain/core/prompts';
import { StringOutputParser } from '@langchain/core/output_parsers';

// 1. 初始化模型和输出解析器
const model = new ChatDeepSeek({
  model: 'deepseek-reasoner',
  temperature: 0.7,
});
// 输出解析器:直接提取模型返回的文本内容,不用再写res.content
const outputParser = new StringOutputParser();

// 2. 创建提示词模板(推荐用ChatPromptTemplate,适配对话模型)
const prompt = ChatPromptTemplate.fromTemplate(`
  你是一个{role},请用不超过{limit}个字符回答:{question}
`);

// 3. 用LCEL管道符串起整个流程,一行搞定Chain!
const chain = prompt | model | outputParser;

// 4. 调用Chain,就是这么简单
const res = await chain.invoke({
  role: '前端架构师',
  limit: 50,
  question: '怎么快速学好React?'
});

console.log(res);

看到差距了吗?LCEL 写法逻辑清晰,每一步做什么一目了然,没有冗余的类实例化,模块之间完全解耦,想改哪一步直接替换就行,比如想换个模型,直接把 model 变量换了,其他代码一行不用动。

2.2 LCEL 的黑魔法:生产级能力开箱即用

你以为 LCEL 只是写法简洁?它真正的王炸,是自带了一堆生产级的能力,不用你自己手写一堆胶水代码。

能力 1:流式输出,一行代码搞定打字机效果

上一篇的 Demo 里我们只用了invoke方法,但是做聊天应用,必须要有流式输出的打字机效果,LCEL 里直接用stream方法就行,简单到离谱:

javascript

// 还是上面那个chain,一行代码实现流式输出
const stream = await chain.stream({
  role: '前端架构师',
  limit: 100,
  question: '怎么快速学好Vue3?'
});

// 遍历流,逐字输出,前端直接对接打字机效果
for await (const chunk of stream) {
  process.stdout.write(chunk);
}

能力 2:自动重试 + 降级容灾,再也不怕 API 崩了

生产环境最怕什么?模型 API 超时、限流、挂了。LCEL 自带withRetrywithFallbacks方法,直接给你的服务上双保险:

javascript

import 'dotenv/config';
import { ChatDeepSeek } from '@langchain/deepseek';
import { ChatOllama } from '@langchain/ollama';
import { ChatPromptTemplate } from '@langchain/core/prompts';
import { StringOutputParser } from '@langchain/core/output_parsers';

// 主模型:DeepSeek在线模型
const primaryModel = new ChatDeepSeek({
  model: 'deepseek-reasoner',
  temperature: 0.7,
}).withRetry({
  stopAfterAttempt: 3, // 失败最多重试3次
});

// 备用模型:本地部署的Ollama模型,完全不依赖外网
const fallbackModel = new ChatOllama({
  model: 'qwen:7b',
  temperature: 0.7,
});

// 带降级的模型:主模型失败,自动切备用模型
const modelWithFallback = primaryModel.withFallbacks({
  fallbacks: [fallbackModel],
});

// 构建Chain
const prompt = ChatPromptTemplate.fromTemplate(`
  你是一个{role},请用不超过{limit}个字符回答:{question}
`);
const outputParser = new StringOutputParser();
const chain = prompt | modelWithFallback | outputParser;

// 调用的时候,完全不用关心底层的重试和降级,安心用就行
const res = await chain.invoke({
  role: '前端架构师',
  limit: 50,
  question: '怎么学好TypeScript?'
});
console.log(res);

就这么几行代码,你就实现了生产级的重试和降级容灾,再也不怕 API 挂了导致服务不可用。

能力 3:并行执行,大幅提升接口响应速度

遇到需要同时调用多个模型、或者多个步骤的场景,LCEL 支持并行执行,不用你自己写 Promise.all,直接提升响应速度。

比如做一个 AI 文案生成工具,需要同时生成标题、正文、结尾,用 LCEL 的并行写法,直接同时执行,不用串行等待:

javascript

import { RunnableParallel, RunnablePassthrough } from '@langchain/core/runnables';
import { ChatDeepSeek } from '@langchain/deepseek';
import { ChatPromptTemplate } from '@langchain/core/prompts';
import { StringOutputParser } from '@langchain/core/output_parsers';

const model = new ChatDeepSeek({ model: 'deepseek-reasoner' });
const outputParser = new StringOutputParser();

// 标题生成Chain
const titleChain = ChatPromptTemplate.fromTemplate('给文章主题{topic}生成3个爆款标题') | model | outputParser;
// 正文生成Chain
const contentChain = ChatPromptTemplate.fromTemplate('给文章主题{topic}生成100字的正文') | model | outputParser;
// 结尾生成Chain
const endChain = ChatPromptTemplate.fromTemplate('给文章主题{topic}生成一个引导点赞关注的结尾') | model | outputParser;

// 并行执行三个Chain,同时返回结果
const parallelChain = RunnableParallel({
  title: titleChain,
  content: contentChain,
  end: endChain,
  // 把原始输入也透传下去
  topic: new RunnablePassthrough(),
});

// 一次调用,同时拿到三个结果,响应速度直接拉满
const res = await parallelChain.invoke('前端进阶学习指南');
console.log(res);

三、核心场景进阶实战:从玩具 Demo 到生产级应用

上一篇我们讲了基础的 Demo,这一节咱们直接升级,解决大家做项目时真正会遇到的痛点问题。

3.1 提示词工程进阶:告别瞎写 Prompt,用模板体系拿捏模型输出

很多同学写 Prompt 就是随便写一句话,结果模型输出的内容忽好忽坏,完全不可控。其实 LangChain 已经给你提供了完整的提示词模板体系,帮你稳定模型输出。

进阶 1:用 ChatPromptTemplate 替代 PromptTemplate,适配对话模型

现在我们用的几乎都是对话大模型(Chat Model),而不是旧的补全模型,用ChatPromptTemplate可以精准控制消息的角色(系统提示、用户消息、AI 消息),效果比PromptTemplate好太多。

javascript

import { ChatPromptTemplate } from '@langchain/core/prompts';

// 精准定义系统提示、用户消息,角色分离,模型更听话
const prompt = ChatPromptTemplate.fromMessages([
  // 系统提示词:给模型定规矩,写在这里不会被用户的输入轻易绕过
  ["system", "你是一个资深的前端架构师,回答必须简洁专业,只讲干货,不写废话,每一条回答不超过3条要点"],
  // 用户消息:用占位符接收用户输入
  ["human", "用户的问题:{question},相关技术栈:{techStack}"],
]);

// 格式化提示词
const formattedPrompt = await prompt.formatMessages({
  question: 'React项目性能优化怎么做?',
  techStack: 'React 18 + TypeScript'
});

console.log(formattedPrompt);

进阶 2:少样本学习 FewShotPromptTemplate,让模型秒懂你的要求

想让模型按照你的格式输出,与其写一大堆要求,不如直接给几个示例,这就是少样本学习。LangChain 的FewShotChatMessagePromptTemplate可以轻松实现:

javascript

import { ChatPromptTemplate, FewShotChatMessagePromptTemplate } from '@langchain/core/prompts';

// 1. 定义示例:告诉模型你想要的输入输出格式
const examples = [
  {
    input: "Vue和React的区别",
    output: "核心差异:1. 核心理念:Vue渐进式框架,React函数式UI;2. 响应式:Vue双向绑定,React单向数据流;3. 上手难度:Vue更低,React对JS要求更高"
  },
  {
    input: "var和let的区别",
    output: "核心差异:1. 作用域:var函数作用域,let块级作用域;2. 提升:var存在变量提升,let存在暂时性死区;3. 重复声明:var允许,let不允许"
  }
];

// 2. 定义单个示例的模板
const examplePrompt = ChatPromptTemplate.fromMessages([
  ["human", "{input}"],
  ["ai", "{output}"],
]);

// 3. 创建少样本提示词模板
const fewShotPrompt = new FewShotChatMessagePromptTemplate({
  examplePrompt,
  examples,
  inputVariables: ["input"],
});

// 4. 拼接成最终的提示词
const finalPrompt = ChatPromptTemplate.fromMessages([
  ["system", "你是一个资深前端讲师,回答问题必须简洁明了,只列核心差异,不写废话"],
  fewShotPrompt, // 把示例插在这里,模型自动学习格式
  ["human", "{input}"],
]);

// 调用模型,直接输出符合你格式的内容
const chain = finalPrompt | new ChatDeepSeek({ model: 'deepseek-reasoner' }) | new StringOutputParser();
const res = await chain.invoke({ input: "Promise和async/await的区别" });
console.log(res);

用了少样本学习,你会发现模型输出的内容稳定性直接拉满,再也不会出现格式乱飘的情况。

3.2 RAG 系统进阶:解决答非所问,从「能用」到「好用」

RAG(检索增强生成)是大家用得最多的场景,上一篇我们给了基础的 Demo,但是很多同学做完发现,检索出来的内容根本不对,模型永远答非所问。这一节咱们就把 RAG 的核心优化点讲透。

先上基于 LCEL 的生产级 RAG 完整代码

javascript

import 'dotenv/config';
// 文档加载与分割
import { PDFLoader } from '@langchain/community/document_loaders/fs/pdf';
import { RecursiveCharacterTextSplitter } from '@langchain/textsplitters';
// 嵌入与向量数据库
import { OpenAIEmbeddings } from '@langchain/openai';
import { Chroma } from '@langchain/community/vectorstores/chroma';
// LCEL核心组件
import { ChatPromptTemplate } from '@langchain/core/prompts';
import { ChatDeepSeek } from '@langchain/deepseek';
import { StringOutputParser } from '@langchain/core/output_parsers';
import { RunnablePassthrough, RunnableSequence } from '@langchain/core/runnables';

// ========== 第一步:文档处理与向量库构建 ==========
// 1. 加载PDF文档
const loader = new PDFLoader('./前端开发规范.pdf');
const docs = await loader.load();

// 2. 文本分块(核心优化点!)
const textSplitter = new RecursiveCharacterTextSplitter({
  chunkSize: 500, // 块大小:技术文档推荐300-800,不要太大也不要太小
  chunkOverlap: 100, // 块重叠:保留上下文,避免关键信息被截断
  separators: ["\n\n", "\n", "。", "!", "?", " ", ""], // 按语义分割,不要硬切
});
const splitDocs = await textSplitter.splitDocuments(docs);

// 3. 生成嵌入向量,存入向量数据库
const embeddings = new OpenAIEmbeddings({
  model: 'text-embedding-3-small', // 用专门的嵌入模型,不要用大模型的嵌入能力
});
const vectorStore = await Chroma.fromDocuments(splitDocs, embeddings, {
  collectionName: 'frontend_docs',
});

// 4. 创建检索器
const retriever = vectorStore.asRetriever({
  k: 4, // 只返回最相关的4条内容,不是越多越好
  searchType: 'similarity', // 相似度检索,进阶可以用mmr最大边际相关性,兼顾相关性和多样性
});

// ========== 第二步:构建RAG问答Chain ==========
// 1. 构建RAG专属提示词模板
const ragPrompt = ChatPromptTemplate.fromTemplate(`
你是一个专业的文档问答助手,只能基于下面的参考文档回答用户的问题,绝对不能编造文档里没有的信息。
如果参考文档里没有相关内容,直接回答“抱歉,参考文档中没有相关内容”,不要自己瞎编。

参考文档:
{context}

用户的问题:{question}
`);

// 2. 格式化检索到的文档内容
const formatDocs = (docs) => docs.map(doc => doc.pageContent).join('\n\n');

// 3. 用LCEL构建RAG Chain
const ragChain = RunnableSequence.from([
  // 并行获取检索内容和原始问题
  {
    context: (input) => retriever.invoke(input.question).then(formatDocs),
    question: new RunnablePassthrough(),
  },
  ragPrompt,
  new ChatDeepSeek({ model: 'deepseek-reasoner', temperature: 0 }),
  new StringOutputParser(),
]);

// ========== 第三步:调用问答 ==========
const answer = await ragChain.invoke({
  question: '公司的Git提交规范是什么?'
});

console.log(answer);

RAG 核心优化点,解决答非所问的痛点

  1. 文本分块是重中之重很多人 RAG 效果差,90% 的问题都出在分块上。不要一刀切用 1000 甚至 2000 的 chunkSize,技术文档推荐 300-800 的 chunkSize,一定要加 chunkOverlap,避免关键信息被切在两个块里,还要按语义分割,不要硬把一句话切成两半。
  2. 嵌入模型要选对不要用大模型自带的嵌入能力,专门的嵌入模型(比如 OpenAI 的 text-embedding-3-small、阿里云的 text-embedding-v2)效果好太多,而且成本极低。
  3. 检索结果不是越多越好很多人觉得 k 设得越大,内容越多越好,其实不对。太多无关内容会污染模型的上下文,反而让回答跑偏,一般 k 设 3-5 就足够了。
  4. 提示词一定要加边界限制必须明确告诉模型「只能用参考文档的内容回答,不能编造信息,没有就说不知道」,不然模型会一本正经地胡说八道,这就是 RAG 里最常见的「幻觉问题」。

3.3 多轮对话进阶:聊一百轮也不崩的上下文管理

做聊天机器人,最常见的问题就是聊个十几轮就崩了,要么上下文全忘了,要么 token 直接爆了。核心就是没做好上下文的内存管理。

LangChain 提供了完整的对话内存管理方案,结合 LCEL,轻松实现不崩的多轮对话:

javascript

import 'dotenv/config';
import { ChatDeepSeek } from '@langchain/deepseek';
import { ChatPromptTemplate, MessagesPlaceholder } from '@langchain/core/prompts';
import { StringOutputParser } from '@langchain/core/output_parsers';
import { RunnableWithMessageHistory } from '@langchain/core/runnables';
import { ChatMessageHistory } from '@langchain/community/stores/message/in_memory';

// 1. 构建带历史消息的提示词模板
const chatPrompt = ChatPromptTemplate.fromMessages([
  ["system", "你是一个友好的前端技术助手,专业解答前端相关问题,回答简洁易懂"],
  // 关键:MessagesPlaceholder用来存放历史对话消息
  new MessagesPlaceholder("chat_history"),
  ["human", "{input}"],
]);

// 2. 初始化模型和Chain
const model = new ChatDeepSeek({ model: 'deepseek-reasoner', temperature: 0.7 });
const outputParser = new StringOutputParser();
const chatChain = chatPrompt | model | outputParser;

// 3. 给对话加上历史消息管理
const chainWithHistory = new RunnableWithMessageHistory({
  runnable: chatChain,
  // 按sessionId存储不同用户的对话历史,多用户场景直接用
  getMessageHistory: (sessionId) => new ChatMessageHistory(),
  inputMessagesKey: "input",
  historyMessagesKey: "chat_history",
});

// 4. 多轮对话测试
// 第一轮对话
const res1 = await chainWithHistory.invoke(
  { input: "我现在在学React,应该先学什么?" },
  { configurable: { sessionId: "user_001" } } // 每个用户一个唯一sessionId
);
console.log("AI回答1:", res1);

// 第二轮对话,AI自动记住上下文
const res2 = await chainWithHistory.invoke(
  { input: "那学完这些之后呢?" },
  { configurable: { sessionId: "user_001" } }
);
console.log("AI回答2:", res2);

进阶优化:解决 token 爆仓问题

对话轮次多了,历史消息会越来越长,token 直接就爆了。这时候可以用对话摘要内存,自动把长对话压缩成摘要,只保留关键信息,大幅减少 token 占用:

javascript

import { ConversationSummaryMemory } from 'langchain/memory';

// 初始化摘要内存,自动总结对话历史
const memory = new ConversationSummaryMemory({
  llm: new ChatDeepSeek({ model: 'deepseek-reasoner' }),
  returnMessages: true,
  memoryKey: "chat_history",
});

// 每次对话结束,自动把新的对话内容更新到摘要里
// 不管聊多少轮,摘要都只会保留关键信息,token占用极低

四、LangChain 生产级避坑指南:90% 的人都踩过这些坑

坑 1:乱用 PromptTemplate,对话模型效果直接打对折

很多人不管什么场景都用PromptTemplate,但对于对话模型,ChatPromptTemplate的角色分离能力,能让模型的效果和可控性提升一个量级。记住:只要用的是 Chat 模型,优先用ChatPromptTemplate

坑 2:文本分块一刀切,检索永远答非所问

不要随便抄个 chunkSize=1000 就用,不同的文档类型,分块策略完全不一样:

  • 技术文档、合同条款:chunkSize 小一点,300-800,保证语义完整
  • 小说、长文:chunkSize 可以大一点,800-1500,保留上下文
  • 一定要加 chunkOverlap,一般是 chunkSize 的 10%-20%

坑 3:不做 token 管理,聊几句就爆上下文

永远不要把完整的历史消息全丢给模型,一定要做上下文管理:要么用滑动窗口只保留最近的几轮对话,要么用摘要内存压缩历史消息,不然 token 账单和报错会教你做人。

坑 4:没有错误处理和降级,线上一限流就崩

LLM 的 API 不是 100% 稳定的,超时、限流、报错是常有的事。生产环境一定要加重试机制和降级策略,LCEL 的withRetrywithFallbacks直接用,不要自己手写一堆 try/catch。

坑 5:不用 LCEL,硬写胶水代码,维护到哭

很多人还在用旧的LLMChainRetrievalQA,甚至自己手写 Promise 串流程,代码又臭又长,改一个需求就要重构一半。赶紧拥抱 LCEL,声明式的写法,可组合、可复用、好维护,谁用谁知道。

坑 6:硬编码 API 密钥,上线直接被刷爆欠费

永远不要把 API 密钥硬编码在代码里!一定要用环境变量加载,生产环境用云服务的密钥管理服务,不然代码一提交到 GitHub,密钥直接被爬虫爬走,一夜之间欠费几万块,这种事真的天天都在发生。

五、进阶玩法拓展:LangChain 生态的王炸组合

掌握了上面的内容,你已经能写出生产级的 AI 应用了。如果还想进阶,LangChain 的生态还有两个王炸组合:

  1. LangSmith:LangChain 官方的调试、监控、评估平台,能看到每一次调用的完整链路、token 消耗、耗时,还能给模型的输出做评分,调试 AI 应用跟调试前端代码一样简单。
  2. LangGraph:基于 LangChain 的智能体工作流框架,能实现更复杂的循环、分支、多智能体协作,比如代码生成机器人(写代码→执行→调试→重写)、智能客服机器人(检索→判断→转人工),能实现普通 Chain 做不到的复杂业务逻辑。

结尾

其实 LangChain 从来都不是什么高深的东西,它只是把 AI 应用开发里的通用能力做了封装,让我们这些开发者不用重复造轮子,能专注于业务逻辑本身。

对于咱们前端全栈开发者来说,JS/TS 版本的 LangChain,就是我们进入 AI 应用开发领域最好的入场券 —— 不用学新的语言,不用换技术栈,用你最熟悉的代码,就能写出顶级的 AI 应用。

华创证券:四重维度透视中国宽基指数的“中游制造”成色

36氪获悉,华创证券发布研报称,全球“供给焦虑”下,中国中游制造正步入“出海创收”的战略时代。要获取时代红利,必须穿透宽基指数的标签幻觉,从四大维度甄别底层资产的真实成色。一是看体量与趋势:宽基“中游含量”极度分化且面临系统性抬升。二看虚实与结构:宽基的中游市值背后的盈利支撑呈现显著差异。三看驱动与出海:中游整体出海能力强劲,但指数呈现不同工具属性。四看动力与归因:双重归因揭示了截然不同的演进路径。
❌