当前位置: 首页 > news >正文

k6性能测试入门:从VU模型到CI/CD工程化实践

1. 为什么是k6而不是JMeter或Locust我第一次在生产环境压测一个订单履约服务时用的是JMeter。当时团队刚上线新版本老板说“先跑个500并发看看稳不稳”。我信心满满地搭好JMeter脚本、配置好聚合报告、启动线程组——结果还没到300并发本地笔记本风扇就狂转内存飙到95%GUI直接卡死。最后靠一台4核8G的云服务器才勉强撑住测试但监控数据断断续续日志里全是OutOfMemoryError。更尴尬的是开发同事想复现问题发现他连JMeter的.jmx文件都打不开版本不兼容、插件缺失、路径乱码……一套流程走下来光环境对齐就花了两天。后来我们切到了k6。不是因为“听说它新”而是被逼出来的——我们需要一种能嵌入CI/CD流水线、能用纯JavaScript写逻辑、能单机轻松扛住2000 VUVirtual Users、还能和Prometheus无缝对接的工具。k6做到了它不依赖GUI所有操作通过命令行完成测试脚本就是ES6语法的JS文件前端同事改个断言、后端同事加个header都不用学新DSL最关键是它用Go重写了运行时VU调度轻量高效实测单机8核16G可稳定维持3000并发资源占用不到JMeter的1/5。这背后是架构级差异JMeter是Java写的重型GUI应用每个线程模拟一个用户线程开销大Locust虽用Python协程降低了资源消耗但GIL限制下CPU密集型任务仍受限而k6基于Go的goroutine模型每个VU本质是一个轻量级协程调度由Go runtime统一管理内存占用极低且天然支持多核并行。这不是“语法糖”层面的优化而是运行时底层的重构。所以当你看到k6文档里写着“k6 is a modern load testing tool”别只当它是营销话术——它真正在解决的是“负载测试不该成为运维负担”这个老问题。如果你正面临这些场景CI流水线里要自动触发压测、需要把性能验证写进GitOps工作流、想让测试脚本和业务代码共用同一套ESLint规则、或者只是厌倦了每次压测前都要手动导出JTL文件再拖进Jenkins插件里解析……那k6不是“可选项”而是当前工程化程度下最务实的选择。它不追求炫酷的可视化报表但每行代码都为可维护性、可观测性和可集成性而生。接下来的内容不会教你如何点开GUI勾选“HTTP请求”而是带你从零写出第一个可提交到Git仓库、可被GitHub Actions自动执行、可被Grafana实时渲染的性能测试脚本。2. 从零搭建第一个k6测试脚本不只是“Hello World”很多教程一上来就贴一段export default function() { http.get(https://test.k6.io); }然后告诉你“运行k6 run script.js就完事了”。这就像教人开车只说“踩油门”却不说离合怎么配合、档位怎么切换、雨刮器在哪。真正的入门得从你第一次打开终端、输入第一条命令开始还原。2.1 安装与验证三个必须确认的细节k6官方推荐用包管理器安装但不同系统有隐藏坑macOSM1/M2芯片brew install k6是最稳妥的。千万别用curl https://... | sh那个脚本默认下载x86_64二进制M系列芯片会报bad CPU type in executable。我试过三次每次都是重启终端后才意识到是架构问题。Ubuntu/Debiansudo apt-key adv --keyserver hkp://keyserver.ubuntu.com:80 --recv-keys C5AD17C747E3415A3745A10EA66069F71788B542 echo deb https://dl.k6.io/deb stable main | sudo tee /etc/apt/sources.list.d/k6.list sudo apt-get update sudo apt-get install k6—— 这串命令里最容易错的是apt-key已弃用新版Ubuntu会报错。正确做法是先sudo apt-get install curl gnupg再用curl -sSL https://dl.k6.io/deb/k6-archive-keyring.gpg | sudo gpg --dearmor -o /usr/share/keyrings/k6-archive-keyring.gpg替代。Windows直接下.msi安装包比Chocolatey更可靠。Chocolatey有时会拉取旧版导致k6 version显示v0.43.0却报unknown option --duration——因为--duration是v0.45.0才引入的。安装完务必验证三件事k6 version输出版本号当前最新稳定版是v0.48.0低于v0.45.0请升级k6 inspect script.js能解析脚本结构哪怕脚本是空的k6 run --help能列出完整参数特别确认是否有--duration、--vus、--thresholds等核心选项。提示如果k6 run --help里没有--thresholds说明你装的是阉割版。某些Linux发行版仓库里的k6是精简编译的去掉了指标上报功能。务必从官网https://k6.io/docs/get-started/installation/下载对应平台的完整版。2.2 第一个脚本为什么http.get()不够用写一个能通过的脚本太容易了但生产环境里90%的失败不是因为脚本写错而是因为没处理真实世界的复杂性。我们从一个“看似简单”的需求开始测试登录接口POST /api/v1/auth/login传{ username: test, password: 123456 }期望返回200且响应体含token字段。初学者常这么写import http from k6/http; export default function () { http.post(https://staging.example.com/api/v1/auth/login, { username: test, password: 123456 }); }这脚本能跑通但毫无价值。问题在哪没设请求头Content-Type: application/json缺失后端可能直接返回415 Unsupported Media Type没处理重定向如果登录成功后跳转到/dashboardk6默认跟随重定向但你根本不知道中间发生了什么没加断言即使返回500脚本也标绿你以为成功了没设超时网络抖动时请求卡住整个VU阻塞压测曲线变成一条直线。修正后的最小可用脚本import http from k6/http; import { check, sleep } from k6; export default function () { const url https://staging.example.com/api/v1/auth/login; const payload JSON.stringify({ username: test, password: 123456 }); const params { headers: { Content-Type: application/json, // 实际项目中这里可能还要加X-Request-ID、Authorization等 }, timeout: 10s // 关键避免单请求拖垮整个VU }; const res http.post(url, payload, params); // 四层断言状态码、响应时间、JSON结构、业务字段 check(res, { is status 200: (r) r.status 200, response time 1s: (r) r.timings.duration 1000, has token field: (r) r.json() r.json().token ! undefined, token not empty: (r) r.json().token.length 0 }); sleep(1); // 模拟用户思考时间避免请求洪峰 }注意sleep(1)不是可有可无的装饰。真实用户不会秒发请求漏掉它会导致压测流量远超实际业务峰值误判系统容量。我们曾因没加sleep把一个能扛5000QPS的订单服务“压挂了”事后发现是数据库连接池被瞬间打满——不是服务不行是测试设计错了。2.3 运行与解读看懂k6输出的每一行运行k6 run --vus 10 --duration 30s script.js你会看到滚动的日志。新手常忽略这些信息但它们是调试的黄金线索/\ |‾‾| /‾‾/ /‾‾/ /\ / \ | |/ / / / / \/ \ | ( / ‾‾\ / \ | |\ \ | (‾) | / __________ \ |__| \__\ \_____/ .io execution: local script: script.js output: - scenarios: (100.00%) 1 scenario, 10 max VUs, 30s max duration (incl. graceful stop): * default: 10 looping VUs for 30s (gracefulStop: 30s) INFO[0000] Starting iteration 1 for VU 1... INFO[0000] Starting iteration 1 for VU 2... ... running (00m30.0s), 00/10 VUs, 100 complete and 0 interrupted iterations default ✓ [] 10 VUs 00m30.0s/30s 100/100 iters, 1000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000......这段输出里藏着关键信息10 max VUs你声明了10个虚拟用户但注意不是“同时运行”而是“最多维持10个”100/100 iters共执行100次迭代10 VU × 10秒 ÷ 1秒sleep 100次说明脚本逻辑没卡死最后一长串✓和...✓代表该VU当前迭代成功...是进度条当它卡在某处不动说明有请求超时或脚本阻塞。真正要看的是结尾的汇总报告data_received........: 1.2 MB 41 kB/s data_sent............: 459 kB 15 kB/s http_req_blocked.....: avg12.3ms min2.1ms med8.7ms max45.6ms p(90)28.3ms p(95)35.1ms http_req_connecting..: avg8.2ms min1.3ms med6.4ms max32.8ms p(90)22.1ms p(95)27.5ms http_req_duration....: avg142ms min89ms med135ms max328ms p(90)210ms p(95)245ms ✓ http_req_failed......: 0.00% ✓ http_req_receiving...: avg2.1ms min0.3ms med1.8ms max12.4ms p(90)4.2ms p(95)5.8ms http_req_sending.....: avg0.8ms min0.1ms med0.7ms max3.2ms p(90)1.5ms p(95)2.1ms http_req_tls_handshaking: avg4.2ms min0.5ms med3.1ms max15.6ms p(90)8.3ms p(95)11.2ms http_req_waiting.....: avg135ms min85ms med128ms max312ms p(90)202ms p(95)238ms http_reqs............: 100 3.333333/s iteration_duration...: avg1.42s min1.09s med1.41s max1.72s p(90)1.62s p(95)1.68s iterations...........: 100 3.333333/s vus..................: 10 min10 max10 vus_max..............: 10 min10 max10重点看三组指标http_req_duration服务端处理时间TTFBp(95)245ms意味着95%的请求在245ms内返回这是SLA核心指标iteration_duration单次完整脚本执行耗时含sleepavg1.42s说明每秒每个VU能跑约0.7次10个VU理论QPS7但实际http_reqs3.333/s说明有瓶颈——可能是服务端限流也可能是网络延迟http_req_failed失败率0%但别高兴太早这仅统计HTTP连接失败。你的check()断言失败不会影响这个值得看下面的checks部分。注意k6默认不显示checks结果。要看到断言详情必须加--out jsonoutput.json导出JSON或用--summary-exportsummary.json。这是新手最大误区——以为没报错就全通过其实断言全挂了。3. 进阶核心VU模型、执行阶段与生命周期管理k6最反直觉的设计是它把“用户”抽象成VUVirtual User而非传统意义的“线程”。理解VU的生命周期是写出可靠压测脚本的前提。很多人卡在“为什么我设了100 VU但监控只看到50 QPS”根源就在没搞懂VU调度机制。3.1 VU不是线程而是协程状态机在JMeter里一个线程一个用户一个TCP连接线程数直接对应系统资源消耗。而k6的VU是Go runtime管理的轻量级协程它没有固定绑定的OS线程而是由Go scheduler动态调度到PProcessor上执行。这意味着VU可复用连接同一个VU连续两次http.get()k6默认复用底层TCP连接HTTP/1.1 keep-alive避免反复握手开销VU可暂停/恢复当sleep(2)执行时该VU让出CPUGo scheduler立即调度其他VU运行所以100个VU并不需要100个OS线程VU无状态残留每次export default function()执行完VU的内存上下文被回收下次迭代从头开始——这和Locust的TaskSet不同Locust的实例变量会跨迭代保留。验证这一点很简单写一个脚本在default函数里加console.log(__ENV.K6_VU_ID)然后k6 run --vus 3 --duration 10s script.js。你会看到输出类似INFO[0000] Starting iteration 1 for VU 1... INFO[0000] Starting iteration 1 for VU 2... INFO[0000] Starting iteration 1 for VU 3... INFO[0001] Starting iteration 2 for VU 1... INFO[0001] Starting iteration 2 for VU 2... INFO[0001] Starting iteration 2 for VU 3...注意VU 1执行完第1次迭代后立刻开始第2次而不是等所有VU都跑完第1次才启动第2次。这就是协程调度的体现VU是抢占式调度的没有严格的“轮询”顺序。3.2 执行阶段Stages如何模拟真实流量曲线生产环境的流量从来不是恒定的。大促前1小时流量缓慢爬升零点爆发瞬间冲高之后回落。k6用stages参数精准模拟这一过程。例如k6 run --vus 10 --stages 10s,100;20s,500;30s,1000;10s,500 script.js这表示前10秒维持10 VU → 接下来20秒线性升到500 VU → 再20秒升到1000 VU → 最后10秒降到500 VU。但这里有个致命陷阱--stages控制的是VU数量不是QPS很多团队误以为设了1000 VU就等于1000 QPS结果发现监控QPS只有200。原因在于脚本里的sleep时间。假设你的脚本一次迭代耗时2秒含1秒sleep那么单个VU每秒最多执行0.5次请求1000 VU理论QPS500。如果sleep是5秒QPS直接掉到200。所以正确的做法是先用--vus 10 --duration 30s跑基线记录http_reqs指标算出单VU的QPS再反推目标QPS需要多少VU。公式是目标VU数 目标QPS ÷ (单VU QPS) 单VU QPS http_reqs / duration我们曾为一个支付接口设定目标QPS3000基线测试发现单VU QPS1.2于是--vus 2500起步再根据http_req_duration.p95是否超标微调。3.3 生命周期钩子setup()与teardown()的实战价值setup()和teardown()是k6独有的全局生命周期函数它们在所有VU启动前/结束后各执行一次且只执行一次。这不是语法糖而是解决真实痛点的关键。典型场景压测前需要预热缓存、生成测试数据、获取管理员token压测后需要清理数据库脏数据、关闭长连接、发送测试报告。这些操作如果放在default函数里会被每个VU重复执行1000次造成雪崩。正确用法示例——登录并分发token给所有VUimport http from k6/http; import { check } from k6; // setup() 在所有VU启动前执行一次 export function setup() { const res http.post(https://staging.example.com/api/v1/auth/login, JSON.stringify({ username: admin, password: admin123 }), { headers: { Content-Type: application/json } }); if (!check(res, { admin login success: (r) r.status 200 })) { throw new Error(Admin login failed); } return { token: res.json().token }; // 返回对象供default函数使用 } // default() 接收setup()返回的数据 export default function (data) { const params { headers: { Authorization: Bearer ${data.token}, Content-Type: application/json } }; http.get(https://staging.example.com/api/v1/orders, params); } // teardown() 在所有VU结束后执行一次 export function teardown(data) { console.log(Test completed. Final token: ${data.token?.substring(0, 8)}...); }这里的关键细节setup()返回的对象会作为参数传给default()函数但不是每个VU一份副本而是所有VU共享同一份引用。所以千万别在default()里修改它teardown()的参数是setup()的返回值可用于日志归档或告警如果setup()抛异常整个测试直接中止不会启动任何VU——这是防止“带病压测”的安全阀。经验我们曾因忘记在setup()里加check()导致admin登录失败返回401但setup()没校验继续往下执行所有VU带着空token请求结果压测报告全是401花了两小时排查才发现是前置条件失败。现在所有setup()必加check()和throw。4. 指标驱动自定义指标、阈值告警与结果解读k6的指标体系是其工程化的核心。它不像JMeter只输出TPS、响应时间而是将每一次HTTP请求拆解为12个原子指标并支持自定义业务指标。这才是“可观测性”的起点。4.1 原子指标详解读懂http_req_duration背后的含义当你看到http_req_duration.avg142ms这142毫秒到底包含什么k6将其拆解为http_req_sending请求发送耗时从k6写入socket到内核缓冲区的时间http_req_waiting服务端处理耗时TTFBTime To First Byte即从请求发出到收到第一个字节的时间http_req_receiving响应体接收耗时从收到第一个字节到接收完全部响应的时间。这三个值之和才等于http_req_duration。它们指向完全不同的优化方向http_req_sending高 → 网络带宽不足或客户端负载高http_req_waiting高 → 服务端CPU、数据库、缓存等后端瓶颈http_req_receiving高 → 响应体过大如未压缩的JSON、网络丢包。我们曾压测一个报表接口http_req_duration.p952.1s但http_req_waiting.p952.0shttp_req_receiving.p9580ms。这明确指向服务端查数据库慢。果然发现SQL没走索引加索引后http_req_waiting.p95降到120ms。4.2 自定义指标跟踪业务逻辑成功率k6内置指标只覆盖HTTP层但业务层的成功率更重要。比如“下单成功”不仅要看HTTP状态码还要看响应体里的order_status: created。这时要用Counter、Gauge、Rate等自定义指标。示例统计下单成功/失败率import http from k6/http; import { Counter, Rate } from k6/metrics; // 定义两个指标 const orderSuccess new Counter(order_success); const orderFailure new Counter(order_failure); const orderSuccessRate new Rate(order_success_rate); export default function () { const res http.post(https://staging.example.com/api/v1/orders, JSON.stringify({ product_id: 1001, quantity: 1 }), { headers: { Content-Type: application/json } }); try { const json res.json(); if (res.status 201 json.order_status created) { orderSuccess.add(1); orderSuccessRate.add(1); } else { orderFailure.add(1); orderSuccessRate.add(0); } } catch (e) { orderFailure.add(1); orderSuccessRate.add(0); } }运行后k6 run --out jsonresult.json script.js打开result.json你会在metrics字段里看到order_success: {count: 98, rate: 0.98}, order_failure: {count: 2, rate: 0.02}, order_success_rate: {rate: 0.98}注意Rate类型指标会自动计算比率无需手动除法。这比在check()里写断言更灵活——断言失败只影响checks指标而自定义指标可参与阈值告警。4.3 阈值告警Thresholds让测试自动判定成败CI/CD里不能靠人盯报告。k6的thresholds让测试具备“自检”能力。在脚本顶部加export const options { thresholds: { // HTTP层 http_req_duration: [p(95)200], // 95%请求200ms http_req_failed: [rate0.01], // 失败率1% // 业务层 order_success_rate: [rate0.99], // 业务成功率99% // 自定义指标 iteration_duration: [p(90)1500] // 单次迭代1.5秒 } };当任意阈值不满足k6会以非零退出码结束如exit code 101CI流水线自动标红。这比人工检查p95数字可靠得多。但阈值设计有讲究p(95)比avg更有意义因为平均值会被长尾拖高rate0.01比count0更合理允许极少量抖动避免过度敏感p(99)100这种阈值在分布式系统里几乎不可能稳定达成会导致误报。我们线上SLO是“99%请求500ms”所以阈值设为p(99)500。压测时若不达标流水线直接中断发布强制开发介入。实战技巧阈值可动态配置。用--env STAGEprod传入环境变量在options里读取export const options { thresholds: { http_req_duration: __ENV.STAGE prod ? [p(95)200] : [p(95)500] } };这样一套脚本适配多环境无需维护多份。5. 生产就绪环境隔离、数据驱动与CI/CD集成入门脚本跑通只是开始。真正的挑战是如何让k6成为研发流程中可靠的一环。这要求解决三个问题环境配置如何管理、测试数据如何生成、结果如何融入DevOps。5.1 环境配置用--env和__ENV解耦脚本与环境硬编码URL、token、用户名是灾难之源。k6提供--env参数注入环境变量k6 run --env API_URLhttps://staging.example.com --env USERNAMEtestuser script.js脚本中用__ENV.API_URL读取export default function () { const url ${__ENV.API_URL}/api/v1/login; const payload JSON.stringify({ username: __ENV.USERNAME, password: __ENV.PASSWORD || 123456 }); // ... }但要注意__ENV只在脚本加载时读取一次不能在default()函数里动态修改。所以密码这类敏感信息绝不能写死在命令行会留在shell历史而应从文件读取# 创建.env文件gitignore echo PASSWORDsupersecret .env # 用source加载到环境 source .env k6 run --env API_URLhttps://staging.example.com script.js5.2 数据驱动CSV与JS数组的取舍压测需要真实数据。k6原生支持CSV但新手常踩坑CSV文件必须UTF-8无BOM编码否则中文乱码第一行必须是列名且列名不能有空格或特殊字符k6按行读取每行一个VU的数据但VU数可能远大于CSV行数——此时会循环读取。示例users.csvusername,password test001,pass001 test002,pass002 test003,pass003脚本中import { SharedArray } from k6/data; const users new SharedArray(users, function () { return JSON.parse(open(./users.csv)); // k6自动解析CSV为数组 }); export default function () { const user users[Math.floor(Math.random() * users.length)]; http.post(${__ENV.API_URL}/login, JSON.stringify({ username: user.username, password: user.password })); }SharedArray确保数据在所有VU间共享避免每个VU都加载一遍CSV。但CSV适合静态数据动态数据如实时生成的订单号还得用JS数组随机算法。5.3 CI/CD集成GitHub Actions实战模板把k6嵌入CI关键是三点安装k6、上传报告、失败告警。以下是我们正在用的GitHub Actions模板name: Performance Test on: push: branches: [main] paths: [load-tests/**] jobs: k6-test: runs-on: ubuntu-latest steps: - uses: actions/checkoutv3 - name: Install k6 run: | curl -Ls https://go.k6.io/k6 | sh -s -- -b /usr/local/bin v0.48.0 - name: Run k6 test env: K6_API_URL: ${{ secrets.API_URL }} K6_USERNAME: ${{ secrets.TEST_USERNAME }} run: | k6 run \ --vus 50 \ --duration 60s \ --thresholds http_req_duration:p(95)300 \ --out jsonresults.json \ load-tests/staging-login.js - name: Upload test report uses: actions/upload-artifactv3 with: name: k6-report path: results.json - name: Post to Slack on failure if: ${{ failure() }} uses: 8398a7/action-slackv3 with: status: ${{ job.status }} fields: repo,commit,workflow,job,branch env: SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}关键点paths: [load-tests/**]确保只在性能测试脚本变更时触发避免每次提交都压测--out jsonresults.json生成结构化报告便于后续分析if: ${{ failure() }}只在k6非零退出时发Slack告警避免噪音密钥用secrets管理绝不硬编码。这套流程上线后性能回归从“人肉执行”变成“代码提交即验证”新功能合并前自动卡点团队对性能的信心大幅提升。最后分享一个血泪教训我们曾把--vus 1000直接写进CI脚本结果某次主干合并触发压测瞬间打垮了测试环境数据库。现在所有CI中的VU数都用--vus ${{ secrets.K6_VUS }}并通过PR评论指令动态调整比如评论/k6-run --vus 200就只跑200并发。安全永远比速度重要。
http://www.gsyq.cn/news/1390571.html

相关文章:

  • 告别默认丑界面!手把手教你用YAML文件自定义Rime鼠须管皮肤(macOS专属)
  • 3步终结环世界模组混乱:RimSort让你从崩溃到流畅的终极指南
  • Windows 10/11下北醒TF雷达上位机安装与避坑指南(附.Net Framework 4.5.2配置)
  • 基于向量数据库与本地嵌入模型构建AI助手持久记忆系统
  • 会议纪要自动生成器哪个好?高识别快整理省心又清晰
  • 贵阳黄金上门回收哪家强?福运来实力领跑 - 黄金回收
  • 从VBA到C#:CATIA遍历结构树的两种经典方法对比与实战避坑
  • 大模型应用中的复杂性代价:从数据过载到精准输出的工程实践
  • OpenClaw与Continue.dev深度对比:AI编程助手如何重塑开发工作流
  • Hotkey Detective终极指南:3分钟解决Windows热键冲突的完整教程
  • 别再纠结点对点距离了!用Python实现基于网格的轨迹相似度计算(附CSIM算法实战代码)
  • 告别串口助手!用App Inventor 2 WxBit版自制蓝牙调试App,5分钟搞定Arduino通信
  • 义乌家家旺空调维修:海宁靠谱的空调移机公司有哪些 - LYL仔仔
  • SchoolCMS:如何用开源系统彻底改变学校教务管理?终极指南
  • 【逆向工程实战】揭秘IL2CppDumper如何从Unity二进制文件中提取完整C#元数据
  • 会议纪要录音转文字,精准识别高效整理更省心省力
  • 别再死记硬背公式了!用MATLAB手把手教你搞定奈奎斯特稳定判据(附避坑指南)
  • UE5.5 PCG Framework地形布点原理与工程化实践
  • DVC数据版本控制实战:让Git管理CSV和模型文件
  • 大语言模型应用安全:超越用户输入的提示词注入防御实战
  • 快速实现无人机RemoteID合规的完整开源方案指南
  • 在Taotoken平台观测不同大模型API的用量与成本对比分析
  • PyCharm运行配置全解析:从Edit Configurations到Project Interpreter的避坑指南
  • 2026 东莞黄金回收商家排行,紧跟实时金价出价公道实在 - 薛定谔的梨花猫
  • SVG图标字体化难题:如何通过svg2ttf实现高效矢量转换与专业字体生成?
  • 会议纪要自动生成器,AI技术带来的省心清晰纪要整理
  • Topit:Mac窗口置顶终极指南 - 提升多任务处理效率的完整教程
  • WarcraftHelper:让经典魔兽争霸3在现代电脑上流畅运行的终极解决方案
  • VMware Workstation Pro 17免费许可证密钥:终极激活与使用指南
  • 在ubuntu上配置openclaw使用taotoken作为其ai提供商