XSS概述对于前端开发而言，应该都是知道这个漏洞的。全称为：跨站脚本（Cross Site Script），因为简写成CSS会冲突，所以叫做XSS这个漏洞的本质其实就是通过前端提供出去的功能，可以注入JS代码，然后可以被前端执行来一个简单的实例： ... 输入测试内容 提交 清除 输出结果 输出内容将显示在这里... ... 这里只把需要用到的地方列出来了。html代码自己尝试的时候可以交给AI补全。这就是一个输入框，然后下方还有一个输出内容的区域，从下面的前端代码可以看到，获取到内容之后，通过innerHTML属性以标签的方式插入内容，这就是一个典型的 反射XSS通常，验证XSS的方法，就是通过alert()或者console.log()函数憨憨是否会回显进行简单的验证。由于Chrome浏览器本身也有一定的XSS的防护机制，直接使用： 就可以看到弹窗了，这样，我们就证明了漏洞的存在，同时，我们F12打开开发者工具， 也可以看到img标签被写入了进去：危害XSS的核心危害就是通过在网站里植入恶意的JS代码，窃取用户cookie或者利用用户cookie进行未授权操作，通俗来说，就是窃取凭证，盗号。cookie是账号的唯一凭证，拿到了它就相当于拿到了账号权限，这也就是XSS漏洞的核心目标。当然了，除了这个核心目标之外，还有一些针对于前端页面的危害：篡改页面内容，诱导用户操作，进行钓鱼攻击植入恶意的连接，对页面进行重定向通过引入外链的方式绕过内容安全审查策略，打广告或者非法宣传。具体来说就是通过 标签引入违规图片或者利用标签引入外链。（注意区分：如果一个触发点不能执行JS代码，而是只能植入HTML标签，那么这一类的漏洞不能称之为XSS，而是称之为HTML注入）XSS类型对于XSS漏洞的分类，我根据 触发方式、触发位置、触发影响 的不同，可以有下面三种方式：触发方式可以分为 单次触发 长期触发 ，分别对应的是 反射型XSS 和 存储型XSS反射型通过前端输入立即触发执行，直接渲染到前端里，不持久存储在服务器中。上面的那个动图就是经典的反射型XSS。经常发生在包含JS代码的恶意链接，多用于点击挟持，钓鱼等攻击场景，需要主动触发JS代码。存储型顾名思义，就是持久性存储在服务器数据库中的。漏洞点多为恶意代码通过POST请求写入后端数据库，每次访问该页面的时候，后端从数据库中拉取恶意数据返回，前端拿到数据后渲染的时候都会触发并且执行JS代码，属于是被动触发。这种被动触发的攻击方式也可以叫做 路过攻击 或者 水坑攻击，在你神不知鬼不觉的时候触发了恶意代码，所以这一类XSS危害较大，影响面更广，常见于一些持久性存储的区域，比如论坛帖子。个人信息查看页面。触发位置可以分为 DOM渲染 和 代码触发，可以对应为 DOM型XSS 和 非DOM型XSSDOM型所谓DOM节点，通俗的说就是HTML里面的标签，那么该类型的XSS的特征就是在各种标签中触发JS代码，上面的实例漏洞demo就是这样的，核心漏洞触发点就是document标签element对象中的innerHTML属性document.createElement("X").innerHTML document.getElementById("xx").innerHTML通过页面逻辑创建了一个 简单来说就是通过路由获取到用户输入的内容，然后直接执行用户输入的内容。比如说我现在访问：http://example.com/reflected_xss_demo.html?input=alert('XSS')那么代码就会变成：eval("alert('XSS')");于是乎就发生了弹窗。Self-XSS浏览XSS分类教程时，有看到这么一个特殊的分类：Self-XSS ，这种其实都不能算安全漏洞：举个例子👇🏻现在有一个页面存在XSS漏洞，但是只能由你自己访问这个页面，比如说个人资料页面。其他用户无法看到这个页面，所以危害就无法扩散到其他用户，这类型顶多算代码缺陷，完全无危害，这一点从名字来看，就知道了。但是这种缺陷如果被发现之后从安全层面角度还是要修复的，只是低优先级而已。因为你无法保证未来的增量代码逻辑肯定不会引用存在Self-XSS的页面，这个时候就有危害了，而且也不再属于Self-XSS。所以说现实中，当一些白帽子发现了Self-XSS的时候，会努力地找其他可以影响其他用户的触发点，也可能会每隔一段时间看看是否有新增的功能可以扩大该漏洞的利用空间。不然一个Self-XSS正常是不会被SRC收录的。漏洞实战漏洞场景这里我们来模拟一下简化之后的存储XSS漏洞场景——留言板系统，也是业务中比较常见的漏洞点，我们可以借此来看一下危害：需求我们有一个留言板应用，用户可以提交评论，这些评论会被存储在服务器的数据库中然后展示给所有的访客，但是在留言处存在了XSS漏洞。（篇幅有限，为了展现单一漏洞危害，所以对业务逻辑进行了大量的简化）源码用了最基础的HTML+CSS+JS，使用localstorage来记录留言，以此来达到存储型+DOM型的XSS漏洞实例，现在灵活使用AI功能可以快速生成需要的代码：HTML 存储型XSS漏洞演示 - 留言板系统 🛡️ 存储型XSS漏洞演示 留言板系统 - 网络安全教学演示 ⚠️ 安全警告: 此页面仅用于教育演示目的，展示存储型XSS漏洞原理。所有用户输入都会被直接存储和渲染，存在严重的XSS安全漏洞。请勿在生产环境中使用！ 📝 发表留言 用户名： 留言内容： 💬 发布留言 🗑️ 清空所有留言 0 总留言数 0 检测到XSS 💭 留言列表 暂无留言，快来发表第一条留言吧！ 🔍 XSS漏洞演示说明 存储型XSS原理：用户输入的恶意脚本被存储在数据库中（本演示使用localStorage），当用户访问页面时，恶意脚本会被执行，影响所有访问用户。 本系统漏洞：系统不对用户输入进行任何HTML过滤，直接将用户输入存储并使用innerHTML渲染到页面中。 🧪 XSS测试用例（点击复制到剪贴板）： 复制 复制 点击我 复制 复制 复制 复制 🔒 安全修复方法：对所有用户输入进行HTML转义，使用textContent代替innerHTML，实施内容安全策略(CSP)，验证和过滤用户输入。 JS// 数据存储相关函数 function getComments() { try { const comments = localStorage.getItem("xss-comments"); return comments ? JSON.parse(comments) : []; } catch (error) { console.error("读取评论数据出错:", error); return []; } } function saveComments(comments) { try { localStorage.setItem("xss-comments", JSON.stringify(comments)); return true; } catch (error) { console.error("保存评论数据出错:", error); return false; } } // 初始化示例数据 function initializeData() { const comments = getComments(); if (comments.length === 0) { const initialComments = [ { id: 1, username: "系统管理员", comment: "🎉 欢迎来到存储型XSS漏洞演示系统！ ⚠️ 重要提醒：此系统故意包含XSS安全漏洞，仅用于教学演示目的。", timestamp: new Date().toISOString(), isXSS: false, }, { id: 2, username: "安全研究员", comment: "这是一个很好的XSS漏洞学习案例。系统直接将用户输入插入HTML页面，没有进行任何过滤和转义。建议尝试右侧的测试用例。", timestamp: new Date(Date.now() - 300000).toISOString(), isXSS: false, }, { id: 3, username: "学习者", comment: "测试HTML标签：斜体文字 和 粗体文字 蓝色文字", timestamp: new Date(Date.now() - 600000).toISOString(), isXSS: false, }, ]; saveComments(initialComments); } } // XSS检测函数 function detectXSS(text) { const xssPatterns = [ /<script[\s\S]*?>[\s\S]*?<\/script>/gi, /javascript:/gi, /on\w+\s*=/gi, /<iframe/gi, /<object/gi, /<embed/gi, /eval\s*\(/gi, /expression\s*\(/gi, ]; return xssPatterns.some((pattern) => pattern.test(text)); } // 添加评论函数 - 关键漏洞点：不进行任何过滤 function addComment(username, comment) { const comments = getComments(); const isXSS = detectXSS(comment) || detectXSS(username); const newComment = { id: Date.now(), username: username, // 直接存储，不转义 - XSS漏洞点 comment: comment, // 直接存储，不转义 - XSS漏洞点 timestamp: new Date().toISOString(), isXSS: isXSS, }; comments.push(newComment); if (saveComments(comments)) { console.log("新留言已保存:", { username, comment: comment.substring(0, 50) + "...", isXSS, }); renderComments(); updateStats(); return true; } return false; } // 渲染评论列表 - 关键漏洞点：使用innerHTML直接渲染 function renderComments() { const comments = getComments(); const commentsList = document.getElementById("commentsList"); if (comments.length === 0) { commentsList.innerHTML = ` 暂无留言，快来发表第一条留言吧！ `; return; } // 按时间倒序排列 comments.sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp)); const commentsHTML = comments .map( (comment) => ` ${comment.username} ${formatTime(comment.timestamp)} ${ comment.isXSS ? "⚠️" : "" } ${comment.comment} ` ) .join(""); // 关键漏洞：直接使用innerHTML，恶意脚本会被执行 commentsList.innerHTML = commentsHTML; } // 时间格式化 function formatTime(timestamp) { return new Date(timestamp).toLocaleString("zh-CN", { year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", }); } // 更新统计信息 function updateStats() { const comments = getComments(); const xssComments = comments.filter((c) => c.isXSS); document.getElementById("commentCount").textContent = comments.length; document.getElementById("xssCount").textContent = xssComments.length; } // 清空所有评论 function clearAllComments() { if (confirm("确定要清空所有留言吗？此操作不可恢复！")) { localStorage.removeItem("xss-comments"); renderComments(); updateStats(); alert("所有留言已清空！"); } } // 复制到剪贴板 function copyToClipboard(element) { const code = element.querySelector("code").textContent; if (navigator.clipboard) { navigator.clipboard .writeText(code) .then(() => { const btn = element.querySelector(".copy-btn"); const originalText = btn.textContent; btn.textContent = "已复制！"; setTimeout(() => { btn.textContent = originalText; }, 2000); }) .catch((err) => { console.error("复制失败:", err); fallbackCopy(code); }); } else { fallbackCopy(code); } } // 备用复制方法 function fallbackCopy(text) { const textarea = document.createElement("textarea"); textarea.value = text; document.body.appendChild(textarea); textarea.select(); try { document.execCommand("copy"); alert("代码已复制到剪贴板！"); } catch (err) { console.error("复制失败:", err); prompt("请手动复制以下代码:", text); } document.body.removeChild(textarea); } // 页面加载完成后初始化 document.addEventListener("DOMContentLoaded", function () { console.log("🎯 存储型XSS演示系统已加载"); console.log("⚠️ 警告：此系统包含XSS漏洞，仅用于安全教学！"); initializeData(); renderComments(); updateStats(); // 表单提交处理 document .getElementById("commentForm") .addEventListener("submit", function (e) { e.preventDefault(); const usernameInput = document.getElementById("username"); const commentInput = document.getElementById("comment"); const username = usernameInput.value.trim(); const comment = commentInput.value.trim(); if (!username || !comment) { alert("请填写用户名和留言内容！"); return; } if (addComment(username, comment)) { // 清空表单 usernameInput.value = ""; commentInput.value = ""; // 提示用户 if (detectXSS(comment) || detectXSS(username)) { alert("⚠️ 检测到潜在的XSS代码！留言已保存，请查看执行效果。"); } else { alert("✅ 留言发布成功！"); } } else { alert("❌ 发布失败，请重试！"); } }); // 添加一些CSS动画效果 document.querySelectorAll(".comment-card").forEach((card, index) => { card.style.animationDelay = `${index * 0.1}s`; card.style.animation = "slideIn 0.6s ease-out forwards"; }); }); // 添加键盘快捷键 document.addEventListener("keydown", function (e) { // Ctrl+Enter 快速提交 if (e.ctrlKey && e.key === "Enter") { document.getElementById("commentForm").dispatchEvent(new Event("submit")); } // Ctrl+D 清空数据 if (e.ctrlKey && e.key === "d") { e.preventDefault(); clearAllComments(); } }); CSS* { box-sizing: border-box; margin: 0; padding: 0; } body { font-family: "Arial", "Microsoft YaHei", sans-serif; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); min-height: 100vh; padding: 20px; line-height: 1.6; } .container { max-width: 900px; margin: 0 auto; background: white; border-radius: 15px; box-shadow: 0 20px 60px rgba(0, 0, 0, 0.2); overflow: hidden; animation: slideIn 0.8s ease-out; } @keyframes slideIn { from { opacity: 0; transform: translateY(30px); } to { opacity: 1; transform: translateY(0); } } .header { background: linear-gradient(135deg, #ff6b6b 0%, #ee5a6f 100%); color: white; padding: 40px 30px; text-align: center; position: relative; overflow: hidden; } .header::before { content: ""; position: absolute; top: -50%; left: -50%; width: 200%; height: 200%; background: radial-gradient( circle, rgba(255, 255, 255, 0.1) 0%, transparent 70% ); animation: rotate 20s linear infinite; } @keyframes rotate { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } } .header h1 { font-size: 2.5em; margin-bottom: 10px; text-shadow: 0 3px 6px rgba(0, 0, 0, 0.3); position: relative; z-index: 1; } .header p { font-size: 1.1em; opacity: 0.95; position: relative; z-index: 1; } .warning { background: linear-gradient(135deg, #ffecd2 0%, #fcb69f 100%); border-left: 5px solid #ff6b6b; color: #8b4513; padding: 20px; margin: 20px; border-radius: 10px; box-shadow: 0 4px 15px rgba(255, 107, 107, 0.2); } .warning strong { color: #d63031; font-size: 1.1em; } .main-content { padding: 30px; } .section { margin-bottom: 30px; padding: 25px; border-radius: 12px; box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08); } .form-section { background: linear-gradient(135deg, #f8f9ff 0%, #e3f2fd 100%); border: 1px solid #e1f5fe; } .comments-section { background: linear-gradient(135deg, #fff9e6 0%, #fff3e0 100%); border: 1px solid #ffe0b3; } .help-section { background: linear-gradient(135deg, #f3e5f5 0%, #e8eaf6 100%); border: 1px solid #d1c4e9; } .section h2 { color: #333; margin-bottom: 20px; font-size: 1.8em; display: flex; align-items: center; gap: 10px; } .form-group { margin-bottom: 20px; } .form-group label { display: block; margin-bottom: 8px; color: #555; font-weight: 600; font-size: 1.1em; } .form-group input, .form-group textarea { width: 100%; padding: 15px 18px; border: 2px solid #ddd; border-radius: 10px; font-size: 15px; transition: all 0.3s ease; background: white; } .form-group input:focus, .form-group textarea:focus { outline: none; border-color: #667eea; box-shadow: 0 0 0 4px rgba(102, 126, 234, 0.15); transform: translateY(-2px); } .form-group textarea { resize: vertical; min-height: 120px; font-family: inherit; } .btn { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 15px 30px; border: none; border-radius: 10px; font-size: 16px; font-weight: 600; cursor: pointer; transition: all 0.3s ease; margin-right: 10px; box-shadow: 0 4px 15px rgba(102, 126, 234, 0.3); } .btn:hover { transform: translateY(-3px); box-shadow: 0 8px 25px rgba(102, 126, 234, 0.4); } .btn-danger { background: linear-gradient(135deg, #ff6b6b 0%, #ee5a6f 100%); box-shadow: 0 4px 15px rgba(255, 107, 107, 0.3); } .btn-danger:hover { box-shadow: 0 8px 25px rgba(255, 107, 107, 0.4); } .comment-card { background: white; border: 1px solid #e9ecef; border-radius: 12px; padding: 20px; margin-bottom: 20px; transition: all 0.3s ease; position: relative; overflow: hidden; } .comment-card::before { content: ""; position: absolute; top: 0; left: 0; width: 4px; height: 100%; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); } .comment-card:hover { transform: translateY(-2px); box-shadow: 0 8px 30px rgba(0, 0, 0, 0.12); } .comment-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 15px; padding-bottom: 10px; border-bottom: 1px solid #eee; } .username { color: #667eea; font-size: 1.2em; font-weight: 600; } .timestamp { color: #999; font-size: 0.9em; } .comment-content { color: #333; line-height: 1.7; word-wrap: break-word; font-size: 1.05em; } .comment-count { color: #667eea; font-weight: 600; } .empty-state { text-align: center; color: #999; font-style: italic; padding: 50px 20px; background: white; border-radius: 12px; border: 2px dashed #ddd; } .empty-state::before { content: "💬"; font-size: 3em; display: block; margin-bottom: 15px; } .payload-examples { background: white; padding: 20px; border-radius: 10px; margin-top: 20px; border: 1px solid #ddd; } .payload-examples h4 { color: #333; margin-bottom: 15px; font-size: 1.2em; } .payload-item { background: #f8f9fa; padding: 12px 15px; margin: 10px 0; border-radius: 8px; border-left: 4px solid #667eea; position: relative; } .payload-item code { font-family: "Consolas", "Monaco", "Courier New", monospace; color: #e91e63; word-break: break-all; font-size: 14px; background: none; padding: 0; } .payload-item .copy-btn { position: absolute; top: 50%; right: 10px; transform: translateY(-50%); background: #667eea; color: white; border: none; border-radius: 5px; padding: 5px 10px; font-size: 12px; cursor: pointer; opacity: 0.7; transition: opacity 0.3s; } .payload-item:hover .copy-btn { opacity: 1; } .stats { display: flex; gap: 20px; margin-bottom: 20px; } .stat-item { background: white; padding: 15px 20px; border-radius: 10px; text-align: center; box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); flex: 1; } .stat-number { font-size: 2em; font-weight: bold; color: #667eea; } .stat-label { color: #666; font-size: 0.9em; } @media (max-width: 768px) { .container { margin: 0; border-radius: 0; } .header { padding: 30px 20px; } .header h1 { font-size: 2em; } .main-content { padding: 20px; } .section { padding: 20px; } .comment-header { flex-direction: column; align-items: flex-start; gap: 8px; } .stats { flex-direction: column; } .btn { width: 100%; margin-bottom: 10px; margin-right: 0; } } 重点在于JS部分，最危险的地方有两点，一个是后续直接使用了innerHTML，一个是在定义newComment的时候，也没有特别处理内容。这里就容易被XSS攻击。当然了，这个实例并没有做成那种还有登录注册系统的，但是你可以想象一下，如果有多用户登录，登陆之后就可以留言，然后留言内容每一个人都可以看到，那么漏洞的影响范围就拉满了。作为攻击者，在留言的时候，写入Payload就完事儿了。动图的内容只是一个可以点击的div块元素，如果上传者上传的是一个img标签，而src无效，那么就会触发onerror，例如onerror里面有hack行为呢？深入的行为和利用就暂时不讨论了，仅仅从直观上演示的话，当JS代码可以被执行的时候，那基本上干什么都可以了。（当然，现代互联网Chrome浏览器之类的本身也有很多的防护策略，来给攻击增加门槛）漏洞修复如我上面所分析的漏洞点，修复的第一要素就是让标签不要生效，一般有很多种修复方式：不要用innerHTML或者document.write()，可以用innerText、textContent代替，上述代码中在获取数据插入留言表的时候，就是因为用的innerHTML才导致的问题对输出展示的特殊字符进行HTML编码转义，比方说：const escapeHTML = (str: string): string => { return str .replace(/&/g, "&") .replace(/</g, "<") .replace(/>/g, ">") .replace(/"/g, """) .replace(/'/g, "'"); }; const msg = " "; const li = document.createElement("li"); li.innerHTML = escapeHTML(msg);对于上文提到的非DOM类型的，减少危险函数的使用不要相信前端的输入，还需要后端进行严格的输入检查，避免出现非法字符针对于HTML转义的修复方案，其实现在很多框架比如Vue和React都是内置了这一类安全防护能力，使用框架开发的好处就是这样本篇说到的这一类攻击其实已经在现代社会很少见了，仅作为科普，浏览器的安全防护措施，可以后面再谈总结XSS的核心就是前端缺陷导致的用户输入JS可以被执行，依赖于标签，依赖于代码逻辑，只要控制住了输入和输出，该转义就转义，不要去用一些危险的函数，在非必要情况下禁用特殊字符，后端也做一下校验就好。
安全漏洞什么是漏洞？bug的由来，大家都知道吧，是一桩趣事，计算机有史以来第一起故障是由一个虫子（bug）引起的，所以bug后来被用于描述计算机程序故障，运行缺陷，逻辑错误等等。然后把找bug的行为称之为debug。在bug包含的场景中，有一些缺陷场景很是特殊，它们本身会破坏程序的本身（拒绝服务），做出程序不该做的危险操作（终端指令执行），甚至可以拿到其他用户的数据（越权）等等。为了区分，下载乃常用 vulnerability（弱点，脆弱性）来特指安全类的bug。在大陆，大众理解的bug就是指代的程序缺陷：按钮点击未响应页面加载异常等等；但是，对于安全类的缺陷常用漏洞来指代。注意，接下来这个文集讲解的所有漏洞基本都泛指安全漏洞我学到这里的时候，有想到计算机病毒，后来查阅了一下相关资料之后，可以简单的说一下区别：计算机病毒本质上就是为了恶意攻击而开发的程序安全漏洞是设计缺陷而被利用的被动弱点这里举个很经典的例子：于2017年5月12日集中爆发的WannaCry勒索病毒，就是利用了Windows系统的SMBv1漏洞组件进行了勒索攻击。远程发送特制的数据包，寻找445端口开启的主机，利用EternalBlue工具远程注入恶意代码，成功之后获取系统的控制权，然后修改用户文件，进行加密，后缀改成.WNCRY，除非支付比特币赎金，否则无法解密。并且还可以横向传播，对局域网的其他主机相继进行感染。这个故事很经典，感兴趣可以搜索相关信息查看。作为信息安全的安全漏洞的反面教程是最为合适的。总而言之，安全漏洞属于程序预期之外的可造成安全问题的缺陷漏洞分类了解完了安全漏洞相关的概念之后，接着来说一下常用的漏洞分类。根据我的学习了解，行业内根据漏洞是否具有普遍性，将其分为了 通用性漏洞 和 事件型漏洞通用性漏洞简单来说就是全网通杀，多见于第三方组件、常用框架、或者是售卖给第三方的系统软件（OA系统）等这种在互联网上有一定数量规模的独立部署或者使用的程序。特点总结：漏洞程序或者组件是第三方提供的漏洞程序或者组件是独立部署或者使用的，不依托于提供商平台漏洞程序或者组件在市面上有一定数量规模的使用举个例子：2021年年底的Log4J2反序列化命令执行漏洞。Log4J2作为一个Java日志记录的知名第三方组件，当自身的漏洞被挖掘之后，全网所有使用该组件的应用也就通通都存在了漏洞。事件型漏洞属于业务自己的应用系统独有的漏洞，可以被称之为某个漏洞点，修改之后就没有安全风险了，可以作为一次是影响到了自己的安全事件进行对待。这一类漏洞通常是跟自身业务逻辑代码挂钩的，与第三方组件或者应用没有关系的。比如说：业务系统有一个接口没有经过授权就可以查看用户的信息，它影响的就仅仅是自身平台的用户，这就是属于事件型漏洞。我这次的文集学习内容也会集中到这一类漏洞。漏洞风险程度了解完漏洞的相关概念，我们来讲讲漏洞的危害，因为这关系到了为什么要修复漏洞，说不定可以就此解决一些疑惑：”为什么如此注重安全？“风险三要素漏洞危害：直接代表一个漏洞可以导致的安全结果，比如：我可以随意执行命令，从而批量获取用户信息，或者可以造成系统拒绝服务。影响范围：表示一个漏洞可以波及到的量级（数据量或者持续时间）；比如：是只能影响到一个用户，还是能够影响到多个用户，是中断服务几分钟，还是中断服务一整天？甚至更久？利用难度：代表一个利用该漏洞时需要付出的成本（资源，时间成本）；比如：一个需要用户交互的漏洞肯定是要比一个可以直接利用的漏洞的利用难度要高。这里说个题外话，有人说黑客为啥不拿下某宝某东？严格意义上来说，没有绝对安全的系统，如果要拿下是有办法的（硬要说的话人就是最大的安全隐患），但是为什么不拿下呢？因为贵，攻击的成本要大于产出，故而没有必要，这就是典型的难度高的例子。风险定级当总结出了漏洞风险三要素之后，就可以正确的看待漏洞的风险承担了。那么，凡是都有个轻重缓急，总不能是个漏洞就马上修复。因为大家平时开发也挺忙的。所以！这里就对漏洞进行了分级，每个级别对应了不同的修复时间：业内的共识对漏洞总共分为了五个等级：严重：十万火急，晚一秒就有巨额损失，马上修复！高危：风险很大，影响很广，利用难度低，需要尽快修复（3天之内）中危：风险适中，利用难度较大，有一定的利用成本，可以暂缓修复（一周到15天左右）低危：风险低，损失有限，修复时间可以缓一缓无危害：直接忽略，没有关系漏洞修复谈完漏洞本身，现在要说一下企业内部为了治理漏洞需要做那些建设：（以下内容直接取自互联网大牛总结）仅仅靠程序上线之后再发现漏洞，那么明显风险已经暴露出去了，网络上多少攻击都盯着呢。所以漏洞治理要融入到程序开发的流程之中，在行内，大家把这个东西叫做软件安全开发生命周期（SDLC)常见的SDLC实践是：需求分析阶段安全意识培训：提高开发安全意识需求风险评估：参与需求评审，指出潜在安全风险开发阶段安全代码插件：IDE内置安全代码插件，随时指出潜在代码风险，在LLM AI时代，也可以通过内置的copliot或者Claude之类的进行指摘安全编码规范：为开发者进行安全编码规范制定，提高代码的整体安全性测试阶段白盒代码审计：自动化代码安全审计工具，对程序进行代码扫描人工安全测试：部署测试环境之后，通过安全人员手工测试发现潜在漏洞上线与运营黑盒扫描：服务上线之后，常态化的对其进行漏洞扫描，随时可以发现风险攻防演练：定期举办安全攻防演练，对服务进行模拟攻击，发现安全问题SRC收录：鼓励社会安全从业者挖掘业务漏洞，给予奖励（分包），增加发现率总结这个是扫盲篇，其实在学习这个内容之前，我对这一块儿也是很模糊的，写下这个扫盲的内容也是为了对安全漏洞有一个更全面的了解，理解安全的重要性。网络安全是开发，乃至于全公司共同的责任。
今天浏览到一份很不错的教程，涉及到了现代业务下的WEB架构安全研发。感觉需要记录一下：前言没有绝对安全的系统对于研发而言，感觉网络安全可能和自己的日常代码没有太大关系，在工作中，实际上我们很多时候感觉安全这个问题离我们很远。在进入公司就职时，就看到过很多信息安全的案例，最严重的情况下，会引发法律风险。想象一下：你辛辛苦苦写的代码，一旦上线就被发现漏洞百出，然后回退代码修复重新上线，耽误发布时间。因为自己代码的小漏洞，公司的重要数据被泄露，甚至引发了法律问题。好像听起来很骇人听闻？这些都是有案例的。一个未经处理的GitHub仓库上传，一次忽略的API鉴权，一次不经意的上线，都会成为问题的导火索。网络安全是什么？首先要清楚：网络只是一个数据通信的载体，真正被攻击的永远都是网络传输的终端、或者是大家写出来的一行行代码。所以说，研发过程才是安全的第一线，而，安全就是日常开发中最容易忽视的问题，所以说提高研发同学的安全意识，养成良好的代码编码习惯就很重要了。当前现状经过我的了解，有一些公司会针对这个现象成立专门的安全团队，主要就是对代码安全，有一个叫做SDL(安全开发生命周期) 的概念：在业务进行项目评审，代码测试，代码上线的时候，或多或少地会有安全部门插手。这些安全手段确实会扼杀很多安全问题的萌芽，但是其实还是无法根绝，始终都会有漏网之鱼。更何况我供职的公司也压根没有这种团队，也没有安全人员进行兜底，这就是最可怕的，意识不到安全问题的存在目的所以，为了弥补我这缺失的安全意识，我决定记录这些学习的过程，并且将其编辑为文集，侧重于基础漏洞代码的修复。根据我个人职业规划的方向，这次的研究方向主要集中在当下WEB架构下的安全，对前后端基础漏洞，业务逻辑漏洞，以及部分数据安全问题进行展开研究，主要针对于代码层面进行漏洞研究最终目的：了解漏洞，熟悉修复方法，知道怎么避免安全隐患
这里对网络安全这个文集罗列一下会用到的一些概念词：Payload：攻击载荷，指可造成攻击行为的输入数据Poc：漏洞验证脚本，仅作漏洞验证，不实际作出攻击行为Exploit：利用漏洞进行攻击的手法、脚本或程序GetShell：通过漏洞获取目标系统的命令行控制权限C2：命令与控制，远程控制受害主机的通信架构白帽子：善意的安全研究人员，帮助企业发现并修复漏洞，SRC常客CTF：夺旗赛（Catch The Flag），在网络安全领域下是一种技术比赛，通过利用漏洞的方式进行解谜APT：高级持续威胁，通过各种手段的长期定向攻击，具有高度隐蔽性脚本小子：缺乏技术深度，依赖现成工具的攻击者
本插件直接安装倒是没有什么问题。。但是在安装之后，因为QQ本身没有处理Markdown的能力，加上大语言模型基本上都是返回的Markdown文本，所以我们需要开启image，使用playwright来进行渲染截图，详细需要注意的点如下：1. 要在nb项目的env里加入配置信息要设置为true2. image的依赖内容需要使用pip install在nb项目下直接运行：pip install 'nonebot-plugin-deepseek[image]'成功之后，这个会直接安装playwright，我们需要在服务器里安装对应的浏览器内核，我们可以干脆一步到位：playwright install接着，我遇到的一个坑是，哪怕安装好playwright的内核，还是无法正常使用Markdown转图片：怎么办呢？插件文档也没有写，在翻阅code之后：发现还需要安装nonebot_plugin_htmlrendernb plugin install nonebot_plugin_htmlrender之后，如果linux系统没有安装中文字体，可以一起运行这个命令：sudo apt install fonts-noto -y然后重启nb之后，就可以正常使用啦！
没想到我才发布一周的AB方案这么快就被我找到了替代的优化版本。。。什么是ANI-RSS？这也是一款基于蜜柑计划的全自动番剧下载工具（敲重点！）和之前介绍的AB（AutoBangumi）一样，ARSS（简称）也是通过RSS订阅番剧，调用的是QBittorrent（当然也支持Aria2与Transmission）下载。但是和AB不同的是，这个ARSS不需要注册蜜柑计划的账号，也不需要在蜜柑官网订阅番剧，所有的操作，全部都在程序内部执行。当然，和AB不太一样的点，就是ARSS支持剧集偏移，并且可以自动识别偏移集数，支持配置不同的RSS订阅链接然后自动洗板；把番剧文件夹挂载到ARSS的前提之下，可以自动跳过已经下载的文件；如果你还设置了做种规则，在完成了做种任务之后，也可以自动删除种子。另外，还提供了简单明了的操作UI，按照星期分类的番剧卡片，移动端也可以方便订阅（有一个前提是你需要在PC上先准备好），随时随地添加新订阅。至于代理，使用TMDB的标题、获取年份，telegram与webHook通知等等，都是AB的原有功能。安装还是和AB方案一样，我们需要先安装docker，docker安装我就跳过了。# 使用 bind mount mkdir -p ${HOME}/RSS-subscribe/arss mkdir -p ${HOME}/RSS-subscribe/qb mkdir -p ${HOME}/RSS-subscribe/bangumi cd ${HOME}/RSS-subscribe touch compose.yml & touch .env将以下内容复制到compose.yml里面：services: qb: image: superng6/qbittorrentee:latest container_name: qb ports: - ${QB_PORT}:${QB_PORT} #qb端口 environment: - PUID=${PUID} - PGID=${PGID} - WEBUIPORT=${QB_PORT} - TZ=${TZ} volumes: - ./qb:/config - ${Bangumi_Location}:/bangumi #番剧挂载路径 restart: unless-stopped networks: - ani-rss ani-rss: image: wushuo894/ani-rss:latest container_name: ani-rss ports: - ${ASS_PORT}:${ASS_PORT} environment: - PORT=${ASS_PORT} - CONFIG=/config - TZ=${TZ} volumes: - ./ani-rss:/config - ${Bangumi_Location}:/bangumi #番剧挂载路径 restart: unless-stopped networks: - ani-rss networks: ani-rss: external: true鉴于 qBit­tor­rent 和 ani-rss 有不少共用变量，因此再创建一个 .env 文件存储这些变量，后续有修改在这个文件中操作就好，注意：不同的终端其中的uid和gid都不太一样，你可以在系统终端里使用：$ id会出现你当前系统的uid和gid，把他们复制到.env里：PUID=501 PGID=20 TZ=Asia/Shanghai QB_PORT=8989 ASS_PORT=7789 Bangumi_Location=/YourPath/RSS-subscribe/bangumi接下来我们来创建docker网络：名为ani-rssdocker network create -d bridge ani-rss持续化启动：docker compose up -d配置qBittorrent打开qb地址配置qBittorrent。应该没有忘记吧？qBittorrent的密码需要在终端里查看，我们使用命令查看日志：docker logs qb输出如下：WebUI 将在内部准备不久后启动。请稍等… ******** 信息 ******** 要控制 qBittorrent，请访问下列地址的 WebUI：http://localhost:8989 WebUI 管理员用户名是：admin 未设置 WebUI 管理员密码。为此会话提供了一个临时密码：NQDSMfujC 你应该在程序首选项中设置你自己的密码登录上去之后，记得修改密码，密码可以参考我之前的文章，其实也不难找：AB安装QBittorrent本身只是作为下载器，不承担订阅RSS的角色，所以我们只需要配置一下下面的内容：为不完整的文件添加扩展名，设置默认保存路径到/bangumi 随机一个连接端口，开启UPnP转发 找个种子测试一下，能够正常下载，QBittorrent就完成啦。ANI-RSS打开 http://localhost:7789 配置 ARSS，ARSS 的默认账户密码均为 admin 。如果你计划在公网中使用，就可以直接修改密码：如果你只是想在内网使用，可以开启IP白名单。填写跳过身份验证的IP，比如127.0.0.1下载设置直接如图所示配置好就行基本设置全局排除建议这样：720 繁日双语 JPTC 内嵌 BIG5其他的选项暂时也用不到，代理和通知之类的，如果有需求可以自行查询ANI-RSS的文档！使用可算到这一步了配置的过程其实也不算复杂，订阅更是简单的很呐。但是在我们开始操作之前，还是要确保一下网络方便畅通无阻！请测试：是否可以直接打开以下网站：TMDB：https://www.themoviedb.org/Bangumi：https://bgm.tv/蜜柑计划：https://mikanani.me/如果都没有问题，那我们就可以添加订阅啦：点击右上角的添加，然后你可以直接输入Mikanani的RSS订阅链接，也可以用这个Mikan的按钮直接访问搜索你想看的内容！：然后就可以傻瓜式订阅启动QBittorrent下载了，感觉还有不少可以折腾的点，但是我们先暂且这样吧！
前排提示：建议换用 ANI-RSS，已追更↓↓↓↓↓ANI-RSS先安装AutoBangumi准备工作以下简称AutoBangumi为AB根据官方文档的建议，为了保证AB在每次更新之后数据和配置不丢失，推荐使用bind mount 或者 Docker volume进行数据和配置的持久化。我们先来创建一个RSS订阅的工具集合的文件夹，（因为我们还需要qBittorrent）# 使用 bind mount mkdir -p ${HOME}/RSS-subscribe/AutoBangumi/{config,data} cd ${HOME}/RSS-subscribe/AutoBangumi# 使用 Docker volume docker volume create AutoBangumi_config docker volume create AutoBangumi_data使用Docker安装AutoBangumi使用以下命令之前请确保处于上面我们创建的AutoBangumi文件夹目录下。这里我们使用Docker-cli部署docker run -d \ --name=AutoBangumi \ -v ${HOME}/RSS-subscribe/AutoBangumi/config:/app/config \ -v ${HOME}/RSS-subscribe/AutoBangumi/data:/app/data \ -p 7892:7892 \ -e TZ=Asia/Shanghai \ -e PUID=$(id -u) \ -e PGID=$(id -g) \ -e UMASK=022 \ --network=bridge \ --dns=8.8.8.8 \ --restart unless-stopped \ ghcr.io/estrellaxd/auto_bangumi:latest接下来让我们访问下面的地址：http://localhost:7892AutoBangumi 默认的账号是admin，密码是adminadmin登录进来之后我们修改一下账号密码：在进行最关键的调整步骤之前，我们需要先安装一下qBittorrent和获取Mikanani的RSS订阅。安装qBittorrent如果你也和我一样，RSS全套订阅工具都放在了root目录下面，那么你可以直接复制下面的内容：mkdir -p ~/RSS-subscribe/qbittorrent/config ~/RSS-subscribe/qbittorrent/downloads && \ docker run -d \ --name qbittorrent \ -e PUID=$(id -u) \ -e PGID=$(id -g) \ -e TZ=Asia/Shanghai \ -e WEBUI_PORT=8080 \ -p 8080:8080 \ -p 6881:6881 \ -p 6881:6881/udp \ -v ~/RSS-subscribe/qbittorrent/config:/config \ -v ~/RSS-subscribe/qbittorrent/downloads:/downloads \ --restart unless-stopped \ lscr.io/linuxserver/qbittorrent:latest注意，qBittorrent在后续的新版本取消了一开始的adminadmin密码，取而代之的是在docker logs里面出现的随机密码，并且在登陆之后该密码失效：登陆之后，记得修改密码：位于Tools > Options的WebUI栏目有我勾起来的这部分，这里修改密码。如此这般，qBittorrent的配置到此结束。获取Mikanani的RSS请注意！此教程全程默认你的网络环境是有VPN的，请不要在没有VPN的环境下尝试，亦或是自行寻找大陆网络替代方案！Mikanani需要登录才可以进行RSS订阅勾选你要的番剧，之后在首页上可以看到RSS订阅的按钮，复制这个按钮的链接。复制后获取到的RSS格式通常来说是这样的：https://mikanani.me/RSS/MyBangumi?token=xxxxxxxxxxxxxxx配置 AutoBangumi填入下载器的地址，端口，用户名和密码 点击Apply 保存配置，AB会重启运行，右上角的圆点变成绿色代表正常运行 点击右上角的添加按钮，勾选聚合RSS，选择解析器类型mikan，填入刚刚复制的RSS地址 等待 AB 解析聚合 RSS，解析完成会自动添加番剧并且下载管理。RSS订阅资源一有更新就会触发下载。很方便吧！
Ubuntu 系统重装后建站完整流程文档（含 Nginx + PHP + Typecho + HTTPS + 自动续期）适用于 Ubuntu 22.04 LTS 系统环境，自编译 Nginx + PHP-FPM 架构，脱离 LNMP 全家桶，含 HTTPS 和自动续期配置。🧱 一、系统准备1. 更新系统sudo apt update && sudo apt upgrade -y2. 安装依赖包sudo apt install build-essential libpcre3 libpcre3-dev zlib1g zlib1g-dev libssl-dev unzip curl -y🌐 二、安装 Nginx（源码方式）1. 下载并编译cd /usr/local/src wget http://nginx.org/download/nginx-1.24.0.tar.gz tar zxvf nginx-1.24.0.tar.gz cd nginx-1.24.0 ./configure \ --prefix=/usr/local/nginx \ --with-http_ssl_module \ --with-http_stub_status_module \ --with-http_gzip_static_module make && sudo make install2. 创建目录结构sudo mkdir -p /usr/local/nginx/conf/sites-enabled sudo mkdir -p /var/log/nginx3. 修改主配置文件 nginx.conf加入：include /usr/local/nginx/conf/sites-enabled/*.conf;⚠️ 如果未修改 nginx.conf 并且每个站点的配置都已经直接加入 /usr/local/nginx/conf/nginx.conf 中，也可以保持现状。推荐使用 include 统一管理，方便扩展与维护。🐘 三、安装 PHP-FPMsudo apt install php8.1 php8.1-fpm php8.1-mysql -y sudo systemctl enable --now php8.1-fpm默认监听路径：/run/php/php8.1-fpm.sock🌐 四、网站文件恢复与配置网站目录统一为：/home/wwwroot/恢复 blog（Typecho）等静态站点：cp -a /opt/server/site/wwwroot/* /home/wwwroot/ chown -R www-data:www-data /home/wwwroot✏️ 五、Nginx 虚拟主机配置参考1. 博客站点（PHP）blog.fufubest.com.confserver { listen 80; server_name blog.fufubest.com; root /home/wwwroot/blog.fufubest.com; index index.php index.html; access_log /var/log/nginx/blog.access.log; error_log /var/log/nginx/blog.error.log; location / { try_files $uri $uri/ /index.php?$args; } location ~ \.php$ { include fastcgi_params; fastcgi_pass unix:/run/php/php8.1-fpm.sock; fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; } }2. 静态站点参考（如 fufubest.com）server { listen 80; server_name fufubest.com; root /home/wwwroot/fufubest.com/dist; index index.html; access_log /var/log/nginx/fufubest.access.log; error_log /var/log/nginx/fufubest.error.log; location / { try_files $uri $uri/ =404; } location ~* \.(jpg|jpeg|png|gif|ico|css|js|svg|woff|woff2|ttf)$ { expires 30d; access_log off; add_header Cache-Control "public"; gzip_static on; } }🔐 六、配置 HTTPS（使用 Certbot）安装 Certbotsudo apt install certbot python3-certbot-nginx -y为每个域名申请证书sudo certbot --nginx -d blog.fufubest.com sudo certbot --nginx -d fufubest.com sudo certbot --nginx -d shader.fufubest.com ...成功后，Nginx 会自动补全 HTTPS 配置。♻️ 七、自动续期与重载新建脚本 /root/shell/certbot-renew-with-nginx.sh#!/bin/bash certbot renew --quiet && /usr/local/nginx/sbin/nginx -s reload新建 systemd 服务单元/etc/systemd/system/certbot-renew-nginx.service[Unit] Description=Renew Let's Encrypt certificates and reload Nginx [Service] Type=oneshot ExecStart=/root/shell/certbot-renew-with-nginx.sh/etc/systemd/system/certbot-renew-nginx.timer[Unit] Description=Daily timer to renew certificates [Timer] OnCalendar=*-*-* 00:00:00 Persistent=true [Install] WantedBy=timers.target启用定时器sudo chmod +x /root/shell/certbot-renew-with-nginx.sh sudo systemctl daemon-reload sudo systemctl enable --now certbot-renew-nginx.timer✅ 检查状态查看计时器是否生效systemctl list-timers | grep certbot手动执行测试sudo systemctl start certbot-renew-nginx.service查看证书有效期sudo certbot certificates📌 附注：自定义配置说明所有站点配置未使用 /etc/nginx/sites-enabled，而是采用自编译路径 /usr/local/nginx/conf/sites-enabled/*.conf 管理。如果未修改 nginx.conf 中的 include 路径，请手动将每个站点配置追加到主配置文件中。blog.fufubest.com 使用 Typecho + PHP-FPM，其余如 shader.fufubest.com、fufubest.com 等为静态打包内容，alist.fufubest.com 为 Docker 容器内的 Alist，通过 Nginx 代理端口转发。
在上一篇文章中，我们是直接用的OpenAI的原生API去使用Function Calling（Tools）功能，正如我们文末总结的那样，自己写需要自己维护历史，写参数类型，自己实现函数的调用，比较繁琐。在这里，我要试试在Langchain使用该功能，会极大的减缓使用门槛，并且很容易集成到现在有的Chain里。在Langchain使用Tools在Langchain里，一般用zod来定义Tools函数的JSON Schema。我们可以专注在参数的描述上面，参数的类型定义和require都交给zod来做。在后续定义Agent Tool的时候，zod也可以加入进行辅助的参数类型检查。deno install zodzod的用法简单讲讲zod怎么用。zod是JS生态常见的类型定义和验证的工具库：import { z } from 'zod' const stringSchema = z.string() stringSchema.parse("hi") stringSchema.parse(123) // Error: Invalid input: expected string, received number这里就会报错：Stack trace: ZodError: [ { "code": "invalid_type", "expected": "string", "received": "number", "path": [], "message": "Expected string, received number" } ]报错信息的可读性也很高，这种交给LLM去读取也是非常有效，可以高效地纠正自己的错误。下面再给一系列的示例来迅速地介绍足够定义Tools参数的zod知识：import { z } from 'zod' // 基础类型 const stringSchema = z.string() const numberSchema = z.number() const booleanSchema = z.boolean() // 数组 const stringArraySchema = z.array(z.string()) stringArraySchema.parse(["hi", "there"]) // 对象 const personSchema = z.object({ name: z.string(), age: z.number(), // 可选字段 isStudent: z.boolean().optional(), // 默认值 home: z.string().default("sichuan") }) // 联合类型 const stringOrNumber = z.union([z.string(), z.number()]) stringOrNumber.parse("hi") stringOrNumber.parse(123)考虑到方便LLM调用，以便于让它理解参数和传递参数，我们一般不会把参数设置的过于复杂，否则容易让LLM犯错。使用zod来定义函数的Schema让我们继续说回之前的那个天气函数，我们用zod来定义Schemaimport { z } from "zod" const getCurrentWeatherSchema = z.object({ location: z.string().describe("The city and state, e.g San Francisco, CA"), unit: z.enum(["celsius", "fahrenheit"]).describe("The unit of temperature") })这里就是定义了两个参数location是string类型，并且添加了描述unit就是枚举类型，并且也添加了对应的描述我们没有指定optional，默认就是require的可以使用zod-to-json-schema来把zod定义的Schema转换为JSON Schema：zod-to-json-schemaimport { zodToJsonSchema } from "zod-to-json-schema" ... const paramsSchema = zodToJsonSchema(getCurrentWeatherSchema) paramsSchema我们就可以转换为OpenAI Tools需要的JSON Schema了：{ type: "object", properties: { location: { type: "string", description: "The city and state, e.g San Francisco, CA" }, unit: { type: "string", enum: [ "celsius", "fahrenheit" ], description: "The unit of temperature" } }, required: [ "location", "unit" ], additionalProperties: false, "$schema": "http://json-schema.org/draft-07/schema#" }紧接着，我们就可以在Model里去使用这个tools定义：import { load } from "dotenv" const env = await load() const process = { env } import { ChatOpenAI } from "@langchain/openai"; const chatModel = new ChatOpenAI({ configuration: { baseURL: process.env.OPENAI_API_URL, }, modelName: process.env.OPENAI_MODEL, temperature: 0, }) const modelWithTools = chatModel.bind({ tools: [ { type: "function", function: { name: "getCurrentWeather", description: "Get the current weather in a given location", parameters: paramsSchema } } ] }) await modelWithTools.invoke("成都的天气怎么样？"); 看看输出：AIMessage { lc_serializable: true, lc_kwargs: { content: "", additional_kwargs: { function_call: undefined, tool_calls: [ { id: "call_IIg2WjdJ7BXU45mDyeXtOtWc", type: "function", function: [Object] } ] }, response_metadata: {} }, lc_namespace: [ "langchain_core", "messages" ], content: "", name: undefined, additional_kwargs: { function_call: undefined, tool_calls: [ { id: "call_IIg2WjdJ7BXU45mDyeXtOtWc", type: "function", function: { name: "getCurrentWeather", arguments: '{"location":"成都, 中国","unit":"celsius"}' } } ] }, response_metadata: { tokenUsage: { completionTokens: 23, promptTokens: 81, totalTokens: 104 }, finish_reason: "tool_calls" } }这里面就是一个AIMessage的信息，携带着跟tool call有关系的信息。看起来和我们之前用原生的OpenAI API的结果是类似的，加了一些Langchain内部用的信息。注意：这里我用到的bind并不是Model特有的一个工具，是所有的Runnable都有的方法，可以把Runnable需要的参数传入进去，返回一个只需要其它参数的Runnable对象。因为绑定Tools之后的Model依然是一个Runnable对象，所以我们可以很方便地把它加到LCEL链里！import { ChatPromptTemplate } from "@langchain/core/prompts" const prompt = ChatPromptTemplate.fromMessages([ ["system", "You are a weather bot. You can answer questions about the weather."], ["human", "{input}"] ]) const chain = prompt.pipe(modelWithTools) await chain.invoke({ input: "成都天气如何?" })我们得到的是一样的结果：AIMessage { lc_serializable: true, lc_kwargs: { content: "", additional_kwargs: { function_call: undefined, tool_calls: [ { id: "call_ukLLcEa57HYHVYKAe3r8jEoO", type: "function", function: [Object] } ] }, response_metadata: {} }, lc_namespace: [ "langchain_core", "messages" ], content: "", name: undefined, additional_kwargs: { function_call: undefined, tool_calls: [ { id: "call_ukLLcEa57HYHVYKAe3r8jEoO", type: "function", function: { name: "getCurrentWeather", arguments: '{"location":"成都, 中国","unit":"celsius"}' } } ] }, response_metadata: { tokenUsage: { completionTokens: 23, promptTokens: 94, totalTokens: 117 }, finish_reason: "tool_calls" } }多tools model同样的，我们也可以在model里面去绑定多个tools，就跟之前我们用OpenAI的API类似。我们再用Zod声明一个getCurrentTimeSchema：const getCurrentTimeSchema = z.object({ format: z.enum(["iso", "locale", "string"]).optional().describe("The format of the time, e.g iso, locale, string") }) const getCurrentTimeParamsSchema = zodToJsonSchema(getCurrentTimeSchema)注意，我们这里使用了optional工具函数，所以输出的JSON Schema就不会把这个参数视为require：{ type: "object", properties: { format: { type: "string", enum: [ "iso", "locale", "string" ], description: "The format of the time, e.g iso, locale, string" } }, additionalProperties: false, "$schema": "http://json-schema.org/draft-07/schema#" }使用多个tools的代码也是一样的：我们重新写一个modelWithMultiTools，会根据用户的输入和上下文去调用合适的Function：... const modelWithMultiTools = chatModel.bind({ tools: [ { type: "function", function: { name: "getCurrentWeather", description: "Get the current weather in a given location", parameters: getCurrentWeatherParamsSchema } }, { type: "function", function: { name: "getCurrentTime", description: "Get the current time in a given location", parameters: getCurrentTimeParamsSchema } } ] }) await modelWithMultiTools.invoke("现在是什么时候？成都的天气怎么样？");控制model对于tools的调用还是一样，我们可以像使用API一样通过tool_choice来控制LLM调用函数的行为：比如我不允许调用函数：强制调用某个函数：如出一辙，也是产生了严重的幻觉。使用Tools给数据打标签数据预处理的时候，给数据打标签是很常规的操作。在LLM没有出来之前，与自然语言有关系的任务基本都是交给了python的深度学习来处理。但是有了LLM之后，除了专业领域之外的绝大部分的任务都可以由它完全胜任。得益于LLM展现出来的非常强大的跨语言理解能力，我们的工具可以针对任何的语言，甚至可以让LLM分辨使用了什么语言。这些任务在LLM之前都需要很复杂的处理才可以实现。我们先来定义一下提取信息的函数Schema：const getTagSchema = z.object({ emotion: z.enum(["pos", "neg", "neutral"]).describe("文本情感"), language: z.string().describe("文本的核心语言（应该是ISO 639-1代码）"), }); const getTagParamsSchema = zodToJsonSchema(getTagSchema);这里有两个参数，一个是态度，一个是语言这里要对于没有做过数据分析的小伙伴们解释一下我的设定：emotion给出的三个参数，pos代表积极的、neg代表消极的、neutral代表中性的。第二个参数是语言，理论上我们会强调提取文言的核心语言种别，来应对部分多语言混杂的情况，如果你对语言的准确性非常的看重，我们可以在这里加入更多的描述，比如：语言占比超过60%视为主体语言。在我们执行数据标签的过程中，就如同我上一篇文章说到的那样，我们要强制调用这个函数，以确保LLM对于任何输入都会执行数据标签的分析。const modelTagging = chatModel.bind({ tools: [ { type: "function", function: { name: "getTag", description: "为特定的文本获取标签", parameters: getTagParamsSchema } } ], tool_choice: { type: "function", function: { name: "getTag", } } })然后我们来组合为Chain：import { JsonOutputToolsParser } from "@langchain/core/output_parsers/openai_tools" const prompt = ChatPromptTemplate.fromMessages([ ["system", "仔细思考，你有充足的时间进行严谨的思考，然后按照指示对文本进行标记。"], ["human", "{input}"] ]) const chain = prompt.pipe(modelTagging).pipe(new JsonOutputToolsParser())这里我们用到的提示词，是一种常用的技巧，有论文验证过，这些词就像是说了就会奏效的魔法单词，加入之后会显著提高输出的质量。这种方法被称为“自我反思”（self-reflection），通过在提示中加入类似的引导语，模型会倾向于生成更准确和详细的回答。另外，我们只需要LLM输出的JSON标签，所以使用JsonOutputToolsParser直接拿到Tools的JSON输出就行了。测试一下：喏，可以看到，我们声明了提取数据中的核心语言，即便是最后一个这种中英双语的情况，也还是可以得到正确的信息。在这里展示的就是LLM的Zero-shot learning能力 （零样本学习）。指的就是：在没有明确的任务示例或训练数据的情况下，模型仅通过prompt来完成一个新的任务使用Tools进行信息提取我们再看tools的另一个用法，那就是信息的提取。和打标签有点像，在实际工程中不做过多的区分。直观上就是打标签给数据打上给定的一些标记信息提取就是LLM理解原始文本之后，提取里面的信息类似于黏贴地址，然后自动识别姓名手机地址。来写一个描述一个人的信息的Schema：const personalExtractionSchema = z.object({ name: z.string().describe("人的名字"), age: z.number().optional().describe("人的年龄"), }).describe("从文本中提取个人信息");把age设计成非必选的number，因为年龄可能是没有的，避免LLM瞎搞一个。通过对整个Object添加describe来让LLM对整个对象有一个更多的理解。随后，基于这个来构建一个更加上层的Schema，从信息里提取到更多的复杂信息：const relationExtractionSchema = z.object({ people: z.array(personalExtractionSchema).describe("提取所有人"), relation: z.string().describe("人与人之间的关系，简洁描述"), }) const schemaParams = zodToJsonSchema(relationExtractionSchema)复用了personalExtractionSchema来构建数组的Schema，提取信息里多人的信息，并且提取文本中人物之间的关系。得益于LLM的良好语言能力，我们只需要简单的给出Prompt就可以让LLM在信息提取任务上有出色的表现。看一下schemaParams的值：{ type: "object", properties: { people: { type: "array", items: { type: "object", properties: { name: [Object], age: [Object] }, required: [ "name" ], additionalProperties: false, description: "从文本中提取个人信息" }, description: "提取所有人" }, relation: { type: "string", description: "人与人之间的关系，简洁描述" } }, required: [ "people", "relation" ], additionalProperties: false, "$schema": "http://json-schema.org/draft-07/schema#" }构建Chain：const modelExtract = chatModel.bind({ tools: [ { type: "function", function: { name: "extractRelation", description: "从文本中提取人与人之间的关系", parameters: schemaParams } } ], tool_choice: { type: "function", function: { name: "extractRelation", } } }) const prompt = ChatPromptTemplate.fromMessages([ ["system", "仔细思考，你有充足的时间进行严谨的思考，然后提取文中的相关信息，如果没有明确提供，请不要猜测，可以仅提取部分信息"], ["human", "{input}"] ]) const chain = prompt.pipe(modelExtract).pipe(new JsonOutputToolsParser())我们继续用“自我反思”的提示词去增强输出的质量，然后用if else的方式来让LLM知道，如果没有明确提供，不要猜测，可以只提取部分信息，来减少LLM的幻想。await chain.invoke({ input: "李雷和韩梅梅是一对情侣" })测试一组简单的任务：[ { type: "extractRelation", args: { people: [ { name: "李雷" }, { name: "韩梅梅" } ], relation: "情侣" } } ]这里我没有提及两人的年龄，LLM也就没有返回了。再来测试一下，并不直白的表达内容的情况：await chain.invoke({ input: "八六今年25岁，是个程序员，我和A，B，C是好朋友，大家年龄都一样" })得到的结果：这就是LLM对语言有理解能力，而不是依靠正则表达式这种传统的匹配规则，所以在语义中隐含的信息也可以提取出来。对于边缘情况，也有比较好的处理：await chain.invoke({ input: "我是八六" })总结在这里，我提及了Langchain里面使用OpenAI tools的情况，通过zod减少了编写Schema的繁琐。学习了使用tools来给数据打标签，以及提取数据。LLM不仅仅是chatbot，也可以处理日常的很多任务，解决传统编码中，要复杂编码才可以解决的问题！
接下来我们将要步入Agent领域。function Calling是？在正式开始之前，我们先来看看一切AI Agent的基础——「Function Calling」这个东西的本质就是给LLM了解和调用外界函数的能力，LLM会根据他的理解，在最合适的时间，返回对函数的调用与参数，然后根据函数调用的结果进行回答。比如，在构建一个旅游计划的chatbot，用户给出问题：“我计划于12月25日到日本涩谷参加圣诞节，请帮我查询那个时间的天气和当地节日规定”。这样，LLM就会判断需要调用12月25日的实时天气API来获取日本涩谷的那一天的天气情况，并且查询涩谷对于圣诞节的安排。根据返回的结果，进行回答。这里，我们先不用Langchain，来用OpenAI官方API去尝试理解一下Function Calling这个API。OpenAI已经将Function Calling改名成了tools，不过目前大多数主流资料依然将其成为Function Calling，我们这里谨遵OpenAI的标准，之后都会以tools来称呼这个APIfunction Calling 案例我们就用获取天气这个经典操作来作为使用案例吧，这个需要实时获取外部API的结果，LLM无法回答。这里我们就继续用回NoteBook+deno，因为可以更细致化地观察每一步运行的过程。我们来使用OpenAI官方库，尝试引入并且初始化：import { load } from "dotenv" const env = await load() ... import OpenAI from "openai"; const openai = new OpenAI({ apiKey: env["OPENAI_API_KEY"], baseURL: env["OPENAI_API_URL"] });创建一个假的获取天气的函数function getCurrentWeather({location, unit="fahrenheit"}) { const weather_info = { "location": location, "unit": unit, "temperature": 72, "forecast": ["sunny","windy"] } return JSON.stringify(weather_info) }然后我们按照OpenAI官方API文档指定的格式，创建这个函数的描述信息：const tools = [ { type: "function", function: { name: "getCurrentWeather", description: "Get the current weather in a given location", parameters: { type: "object", properties: { location: { type: "string", description: "The city and state, eg. 'San Francisco, CA'" }, unit: { type: "string", description: "The unit for temperature", enum: ["fahrenheit", "celsius"], // 限定取值范围 } }, require : ["location"] } } } ]我们来解析一下这里的定义：type: "function"：这里目前只支持Function，是必须指定的内容，没有的话会抛出错误。function：对具体函数的描述name：函数名字，需要和你那边要调用的函数的名字一样，我们才可以实现对函数名的调用description：对函数的描述，可以理解成，提供对工具功能的简要描述，帮助模型理解并正确使用工具parameters：函数的参数，OpenAI使用的是通用的JSON Schema去描述函数的各个参数关于parameters，这里分别详细说一下：外层的type描述参数的顶层数据结构类型，通常就是Objectproperties列出对象中的所有参数，并定义每个参数的名称、类型和约束require，定义必需参数的数组properties里又包含了每个参数属性的详细信息，分别是：type：参数的数据类型（如 string、number、boolean）description：描述参数的用途enum：限定参数值的可选范围default：为参数提供默认值（如果适用）format：限制参数的格式（如 email、uri）然后我们来试试调用LLM的tools功能：const messages = [ { "role": "user", "content": "成都天气如何？" } ] const result = await openai.chat.completions.create({ model: env["OPENAI_MODEL"], messages, tools }) console.log(result.choices[0])我们得到的结果如图：可以看到，content是空的，也就是说大模型没有返回文本信息，只是在tool_calls里声明了一下需要调用的函数内容，参数location变成了“成都，中国”，有意思的是，他给定了我圈出的另一个参数unit，指定为摄氏度。我们来试试英文提问：const messages = [ { "role": "user", // "content": "成都天气如何？" "content": "What's the weather in Chengdu?" } ]这里就没有给出第二个参数了，这里再次体现了LLM基于大数据训练出来的涌现的智能，在这种细节也有体现，不过我们并不会完全依赖，算是一个小彩蛋吧，毕竟LLM是一个黑盒，这种小细节值得记录一下。控制LLM调用函数的行为这里，tools还有一个可以选的参数，是tool_choice，有几种使用方式：none表示，禁止LLM使用任何函数，也就是说，无论用户怎么输入，LLM都不会调用函数auto表示，让LLM自己决定要不要使用函数，LLM的返回值可能是函数调用，也可能是正常的信息最后一种，就是指定一个函数，让LLM强制使用函数，类型是Object，有两个属性：type：只能指定为functionfunction：值为一个对象，有且仅有一个key name为函数名称，比如我们上面的例子：{ type: "function", function: { name: "getCurrentWeather", } }有了这个能力，我们就具备了使用更加细致的颗粒度去调用LLM，比如我们直接禁止LLM使用函数功能：const messages = [ { "role": "user", "content": "成都天气如何？" // "content": "What's the weather in Chengdu?" } ] const result = await openai.chat.completions.create({ model: env["OPENAI_MODEL"], messages, tools, tool_choice: "none" }) console.log(result.choices[0])我们得到的结果是：那如果我们来强制要求调用我之前定义的那个函数，并且我不给他提供那个函数需要的信息呢？const messages = [ { "role": "user", "content": "你好？" // "content": "What's the weather in Chengdu?" } ] const result = await openai.chat.completions.create({ model: env["OPENAI_MODEL"], messages, tools, tool_choice: { type: "function", function: { name: "getCurrentWeather", } } }) console.log(result.choices[0])Opoos！好像它产生了很严重的幻觉问题，定位给定位到北京去了综上所述，我们可以得知，用户并没有提供任何跟天气与城市有关系的信息，但是因为我们强制LLM调用请求天气信息的函数，导致LLM出现了非常严重的幻觉问题，至于，这种强制调用有什么用，据我所知，在我计划学习的周期里，数据提取会需要这个操作。所以我们把这个问题抛给之后的学习过程吧。然后，我们把LLM返回的调用参数，传递给JS的函数里。function getCurrentWeather({location, unit="fahrenheit"}) { const weather_info = { "location": location, "unit": unit, "temperature": 2, "forecast": ["sunny","windy"] } return JSON.stringify(weather_info) } const functions = { "getCurrentWeather": getCurrentWeather }我们修改定义函数的位置，把其参数设计为类似于React function Component那种写法的Object，方便我们在这里调用对应的函数，我们可以直接这样：const functionInfo = result.choices[0].message.tool_calls[0].function const functionName = functionInfo.name const functionParams = functionInfo.arguments console.log(JSON.parse(functionParams)); const functionResult = functions[functionName](JSON.parse(functionParams)) console.log(functionResult)把LLM给出的arguments直接塞给对应函数里，就可以得到响应的结果了：并发调用函数在比较新版的Tools里，引入了并发调用函数的特性。我们可以简单的理解成之前的function Calling每次LLM只会返回对一个函数的调用请求，但是在Tools里可以返回一系列的函数调用，来获取更多的信息，函数之间我们可以并行的调用来节约调用外部API所占用的时间。在旧的function Calling里，只能让LLM依次调用请求，形成串行。比如我现在再写一个获取当前时间的函数：getCurrentTimefunction getCurrentWeather({location, unit="fahrenheit"}) { const weather_info = { "location": location, "unit": unit, "temperature": 2, "forecast": ["sunny","windy"] } return JSON.stringify(weather_info) } function getCurrentTime({ format = "iso" } = {}) { let currentTime; switch (format) { case "iso": currentTime = new Date().toISOString(); break; case "locale": currentTime = new Date().toLocaleString(); break; default: currentTime = new Date().toString(); break; } return currentTime; } const functions = { "getCurrentWeather": getCurrentWeather, "getCurrentTime": getCurrentTime }我们对tools也追加描述：const tools = [ { type: "function", function: { name: "getCurrentTime", description: "Get the current time", parameters: { type: "object", properties: { format: { type: "string", description: "The format of the time, eg. 'iso', 'locale', 'string'", enum: ["iso", "locale", "string"] } }, require : ['format'] } } }, { type: "function", function: { name: "getCurrentWeather", description: "Get the current weather in a given location", parameters: { type: "object", properties: { location: { type: "string", description: "The city and state, eg. 'San Francisco, CA'" }, unit: { type: "string", description: "The unit for temperature", enum: ["fahrenheit", "celsius"], // 限定取值范围 } }, require : ["location"] } } } ]那么，我们来试一下：const messages = [ { "role": "user", "content": "请告诉我现在的时间，和成都的天气" // "content": "What's the weather in Chengdu?" } ] const result = await openai.chat.completions.create({ model: env["OPENAI_MODEL"], messages, tools, }) console.log(result.choices[0])得到的结果是：也是成功获得了两个函数都需要的参数，这就是并行调用。根据函数结果进行回答我们把上述所有内容联系到一起，把函数运行的结果输入给LLM，让LLM参考进行回答。 messages.push(result.choices[0].message); const choices = result.choices[0].message.tool_calls for (const choice of choices) { const cellId = choice.id const functionInfo = choice.function const functionName = functionInfo.name const functionParams = functionInfo.arguments const functionResult = functions[functionName](JSON.parse(functionParams)) messages.push({ tool_call_id: cellId, role: "tool", name: functionName, content: functionResult }) } console.log(messages); 这样处理之后，最后的样子就是：[ { role: "user", content: "请告诉我现在的时间，和成都的天气" }, { role: "assistant", content: null, tool_calls: [ { id: "call_b1EBd02zeYZYKfLIwlemtoYE", type: "function", function: { name: "getCurrentTime", arguments: '{"format": "locale"}' } }, { id: "call_42TvIMQHWYpDyzkB5ExkLN9f", type: "function", function: { name: "getCurrentWeather", arguments: '{"location": "Chengdu, China", "unit": "celsius"}' } } ], refusal: null }, { tool_call_id: "call_b1EBd02zeYZYKfLIwlemtoYE", role: "tool", name: "getCurrentTime", content: "12/29/2024, 9:51:27 PM" }, { tool_call_id: "call_42TvIMQHWYpDyzkB5ExkLN9f", role: "tool", name: "getCurrentWeather", content: '{"location":"Chengdu, China","unit":"celsius","temperature":2,"forecast":["sunny","windy"]}' } ]也就是：一条用户的提问一条LLM对函数的调用一条是我们调用函数的结果然后我们把messages打包发给LLM：const res = await openai.chat.completions.create({ model: env["OPENAI_MODEL"], messages, }) console.log(res.choices[0].message); 得到的结果，就是我们假设的函数得到的结果的总结语句了。总结经过上面的这番折腾，我们成功地完成了给LLM提供外部函数，调用外部函数，传递结果给LLM，让LLM根据结果进行回答的逻辑闭环。但是，发现了吗？为了整个过程，我们需要大量的代码来进行跑通。而且我做了这个逻辑也只考虑了用户提问完之后LLM一定会调用外界API来进行回答的情况，如果，没有调用API呢？是不是要加一个IF？真正的完整的Agent要写的东西还差得远，比如我想要实现一个LLM自主决定行动并且根据行动结果进行下一步行动的Agent，想了想或许还远远不够。接下来来试试用Langchain提供的工具来缩减开发复杂度。。