自己动手写Agent Harness【hooks】:生命周期钩子实现

写在前面:系列是为了帮助大家更好的去理解Agent Harness基础设施,并不是想重复造轮子,真实开发建议选择一个成熟的SDK或Harness框架,才是最合适的选择~

1. 引言:skill 和 rule 都不是强制层,底线靠谁

上一篇《自己动手实现一个 agent:skills 与 rules 机制》把话说到一半:skill 管「能干什么」,rule 管「不能碰什么」,但它俩都是触发时才加载的建议层——模型可以不听。那不能妥协的底线靠谁?靠这篇拆的 hooks。钩子把「纪律」从「让 AI 自觉」变成「由工具强制执行」。 skill 和 rule 是建议,模型「记得的时候」遵守;hook 是脚本,挂上就一定执行,不由模型自觉。一句话点题:AI 不记得你说过什么,但 hook 记得。

举个具体的。系列先导篇第 3 篇《动手开发你的第一个 agent 03:给它划安全边界》里,我把审批写死进流水线——Approver 按档位 decide,danger 档一律 deny。当时这么干没问题,但我越用越别扭:拦什么、放什么,本质是「策略」;策略要能随时改、随时换,不该固化在 harness 主体里,更不该靠模型当次「想起来」。这篇就把写死的审批拆出来,改造成可编程的钩子。

2. 先看两个成熟 harness:Claude Code 的钩子、dsh 的流水线

动手前先看看两个成熟 harness 把「纪律」放在哪。

Claude Code 把纪律做成一堆生命周期钩子。 官方文档(写作日 WebSearch 核实,code.claude.com/docs/en/hooks)列出的钩子事件有二十多个,不同版本数目会浮动。按触发频率分三组:每会话一次的 SessionStart / SessionEnd、每回合一次的 UserPromptSubmit / Stop、每次工具调用一次的 PreToolUse / PostToolUse。钩子本身是脚本(也可以是一个 HTTP 端点、一个 MCP 工具、一段 LLM 提示词),挂上就由 Claude Code 在对应节点自动执行。

最常用的两个,正好一前一后:

  • PreToolUse:工具执行前跑。能拦(返回 deny 就阻止调用)、能改(updatedInput 改写工具参数)。它管的是「出去的调用」——模型要调 Bash 之前,钩子可以先看一眼命令是不是 rm -rf
  • PostToolUse:工具成功后跑。能记(记录结果)、能改(updatedToolOutput 替换模型看到的输出)、能 block。它管的是「回来的结果」——工具跑完了,钩子校验一下输出对不对、记一笔账。

matcher 按工具名过滤(比如只对 Bash 拦),还能用 if 条件按参数过滤(比如只对 rm * 拦)。

dsh 把纪律放在六阶段工具流水线里。 我在《Agent Harness 架构到底需要些什么》拆过 dsh:工具调用要过一条显式的六阶段流水线——prepareExecution(pre-execute waterfall + 审批询问 + 单调守卫)→ tools/execute(timeout/retry)→ 工具 body → createSuccessResult → postExecute(post-execute waterfall)→ finalizeContent + tools/result。权限门禁挂 pre-execute,结果转换挂 post-execute——两道口,一前一后。

两家一个靠钩子、一个靠流水线,做法不同,落点一样:工具调用前有一道闸、调用后有记录。 我把它压成一句:最少要有 pre 和 post 两道口,流水线可以短,不能没有。

3. Demo 先行:事件发布 + 钩子注册表

理论收住,动手。给配套工程加一层钩子,代码在 examples/first-agent/step7-hooks/index.js,零依赖,node 直接跑。

先贴核心:钩子注册表。它干两件事——hook(eventName, fn) 注册处理函数,emit(eventName, context) 在流水线关键节点广播。外部往注册表里「插」策略,harness 主体不认识它们。

// ============ 钩子注册表(HookRegistry)============
// 事件发布 + 注册表:外部通过 hook(eventName, fn) 注入拦截 / 记录函数。
// emit(eventName, context) 在流水线关键节点广播,把控制权交给注册的钩子。
// 这就是「把纪律写进工具」的落点:策略是一组外部函数,harness 主体不认识它们。
export class HookRegistry {
  constructor() {
    this.handlers = new Map() // 事件名 -> Set<fn>
  }

  // hook(eventName, fn):注册钩子,返回「卸载函数」。
  // 卸载函数是这次设计的关键:钩子不是永久生效的,策略可以装上也可以拆下。
  hook(eventName, fn) {
    if (!this.handlers.has(eventName)) this.handlers.set(eventName, new Set())
    this.handlers.get(eventName).add(fn)
    return () => this.handlers.get(eventName)?.delete(fn)
  }

  // emit(eventName, context):广播事件。
  // 返回 { allow: true },或 { allow: false, reason }(有人拦截)。
  // 对可拦截事件,任何一个钩子返回 { allow: false, reason } 就短路返回
  // (fail-fast),后续钩子不再执行;若钩子返回 { allow: true, args },
  // 则把 args 透传给流水线,实现「改写参数」。
  // 真实工程:emit 不应在遇 deny 时立即短路整个广播——真实 harness 会让钩子链按注册顺序
  // await 逐个执行,每个钩子 try/catch 隔离:一个钩子抛错不影响后续钩子与流水线主体;
  // 还会给钩子加超时,防止某个钩子卡死整个 loop。本 demo 简化为「遇 deny 短路返回」。
  async emit(eventName, context) {
    const fns = this.handlers.get(eventName)
    if (!fns || fns.size === 0) return { allow: true }
    let rewrittenArgs
    for (const fn of fns) {
      const res = (await fn(context)) ?? { allow: true }
      if (res.allow === false) {
        return { allow: false, reason: res.reason ?? `blocked by ${eventName}` }
      }
      if (res.args !== undefined) rewrittenArgs = res.args // 最后一个改写参数的钩子生效
    }
    return { allow: true, ...(rewrittenArgs !== undefined ? { args: rewrittenArgs } : {}) }
  }
}

注册表有了,把广播点埋进执行流水线。对比系列先导篇第 2 篇《动手开发你的第一个 agent 02:给它装手和眼睛》那条 pre/execute/post 流水线,改动就一处:pre 和 post 不再写死逻辑,改成 emit 两个事件,把控制权交给钩子。新增一个 ToolBlocked 事件,专门给「被拦」记账。

export class ToolPipeline {
  constructor(registry, hooks) {
    this.registry = registry
    this.hooks = hooks // 事件发布依赖的钩子注册表
  }

  async run(toolCall) {
    const tool = this.registry.get(toolCall.name)
    if (!tool) return { ok: false, error: `unknown tool: ${toolCall.name}` }

    // 1) PreToolUse:工具执行前广播。钩子可以拦,也可以改写参数。
    const pre = await this.hooks.emit('PreToolUse', { toolCall, tool })
    if (!pre.allow) {
      // 被拦:工具 execute 根本没被调用(对照 step3 的 deny,但策略在外面)。
      const blocked = {
        ok: false,
        blocked: true,
        reason: pre.reason,
        error: `pre-hook blocked: ${pre.reason}`,
      }
      // ToolBlocked:专门给「记账」的时机——PostToolUse 语义是「用过了」,
      // 这次没用到,所以单独广播,让拦截这件事也有现场。
      await this.hooks.emit('ToolBlocked', { toolCall, reason: pre.reason, result: blocked })
      return blocked
    }
    // 真实工程:allow/deny/args 的返回形状是本 demo 自定义的约定——真实 harness 会定义标准
    // 事件协议(如 PreToolUse 事件对象含 input 可改、决定 allow/deny),让第三方钩子按协议接入。
    // pre 钩子还能改写参数:返回 { allow: true, args }(如归一化路径、补默认值)。
    const args = pre.args ?? toolCall.arguments

    // 2) execute:真做。失败不崩,转成结果对象。
    let value
    try {
      value = await tool.execute(args)
    } catch (err) {
      const failed = { ok: false, error: `${tool.name} execute failed: ${err.message}` }
      await this.hooks.emit('PostToolUse', { toolCall: { ...toolCall, arguments: args }, result: failed })
      return failed
    }
    const result = { ok: true, content: typeof value === 'string' ? value : JSON.stringify(value, null, 2) }

    // 3) PostToolUse:工具执行后自动广播——记账 / 校验都挂这儿。
    await this.hooks.emit('PostToolUse', { toolCall: { ...toolCall, arguments: args }, result })
    return result
  }
}

这段代码值得读三遍。第一遍看结构:run() 只在正确的位置广播事件,拦不拦、记不记,全由外部钩子决定。第二遍看那个 ToolBlocked:PostToolUse 的语义是「用过了」,被拦的调用根本没执行,不该硬塞给 PostToolUse,所以单独广播,让「拦截」这件事也有现场。第三遍看 loop 的透明性——ReactLoop 完全不感知钩子,被拦的工具对 loop 来说就是一次失败的工具调用,照常回写。

然后挂上演示钩子。pre 这边两个:安全策略拦危险命令、参数改写做路径归一化。

// ============ 演示钩子 1:安全策略(PreToolUse)============
// 这就是「把纪律写进工具」:模型提议什么,钩子审一遍再放行。
// 危险模式清单:这类命令大多不可逆(rm -rf 全家桶),模型一旦提议就拦。
const DANGEROUS_PATTERNS = ['rm -rf', 'rm -r', 'mkfs', 'dd if=', 'shutdown', 'reboot', 'format', 'rd /s', 'del /s']

function safetyPreHook({ toolCall }) {
  if (toolCall.name !== 'run_command') return { allow: true }
  const cmd = String(toolCall.arguments?.command ?? '')
  // 第一层:命中危险模式,直接拦
  const matched = DANGEROUS_PATTERNS.find((p) => cmd.includes(p))
  if (matched) {
    return { allow: false, reason: `危险命令模式「${matched}」被策略拦截` }
  }
  // 第二层:即便命令看起来无害(如 whoami),命令执行类工具也默认禁止——
  // 命令该由人工审批,而不是让模型直接执行。这就是「拦截危险命令类」。
  return { allow: false, reason: '命令执行类工具默认禁止:命令应由人工审批,不交给模型直接执行' }
}
// 真实工程:审批与钩子并存、职责分开——审批走审批服务(一次 fail-closed,定「该不该做」),
// 钩子走扩展点(定「接进来干什么」);step3 的三档审批不会被钩子取代,二者叠加使用。

// ============ 演示钩子 2:参数改写(PreToolUse)============
// pre 钩子不只拦截,还能改写参数。这里演示归一化:把 './x' 写成 'x'。
// 真实系统里常拿它做:路径归一化、密钥脱敏、补默认值、注入租户 ID。
function pathNormalizePreHook({ toolCall }) {
  const path = toolCall.arguments?.path
  if (toolCall.name === 'read_file' && typeof path === 'string' && path.startsWith('./')) {
    const fixed = path.slice(2)
    console.log(`  [pre-hook] 参数改写:${path}${fixed}`)
    return { allow: true, args: { ...toolCall.arguments, path: fixed } }
  }
  return { allow: true }
}

post 这边一个记账,外加一个回合观测。两个账本:audit 记「真的执行了的」,blockedLog 记「被拦下来的」。

// ============ 演示钩子 3:审计记录(PostToolUse + ToolBlocked)============
// 两个账本:audit 记「真的执行了的」,blockedLog 记「被拦下来的」。
// 有 pre 拦、有 post 记,出了事才有现场可以追溯——这就是两道口缺一不可的体现。
const audit = []
const blockedLog = []

function auditPostHook({ toolCall, result }) {
  audit.push({
    name: toolCall.name,
    args: toolCall.arguments,
    ok: result.ok,
    brief: result.ok ? String(result.content).slice(0, 30) : result.error,
  })
  console.log(`  [post-hook] 记录:${toolCall.name}${result.ok ? 'ok' : 'ERR'}(累计 ${audit.length} 次)`)
}

function blockedHook({ toolCall, reason }) {
  blockedLog.push({ name: toolCall.name, args: toolCall.arguments, reason })
  console.log(`  [blocked-hook] 拦截:${toolCall.name} ${JSON.stringify(toolCall.arguments)} —— ${reason}`)
}

// ============ 演示钩子 4:回合观测(TurnEnd)============
// 回合级钩子不拦任何东西,只做「可观测」——追踪、计数、打点。
// 这就是广播点不只工具前后、还要有回合前后的原因:挂了钩子,
// 整个生命周期都看得见,而 loop 一行不用改。
function turnEndHook({ userText, turns }) {
  console.log(`  [turn-hook] 回合结束:${userText} → 用了 ${turns} 个 step`)
}

main() 里组装——钩子是「插」进来的,拦什么、记什么全在外部策略里,流水线一行没改:

  // 真实工程:钩子注册表是内存态,这里 hook() 一注册、重启即失——真实 harness 用配置驱动,
  // 从配置文件(如 .claude/settings.json 的 hooks 段)加载钩子定义,运行时无需改代码。
  const hooks = new HookRegistry()
  hooks.hook('PreToolUse', safetyPreHook) // 安全策略:拦命令执行类 + 危险模式
  hooks.hook('PreToolUse', pathNormalizePreHook) // 参数改写:归一化路径
  hooks.hook('ToolBlocked', blockedHook) // 记账:被拦下来的调用
  hooks.hook('PostToolUse', auditPostHook) // 记账:每次真的执行了的调用
  hooks.hook('TurnEnd', turnEndHook) // 可观测:回合生命周期

跑起来。本机真实输出(逐字取自 step7-hooks/PRACTICE.md,Windows 11 / Node v22.12.0 / mock 模型):

$ node step7-hooks/index.js

=== 场景 1:放行 —— read_file 正常执行,post 钩子每次记账 ===
[user] 读文件 package.json
  [post-hook] 记录:read_file → ok(累计 1 次)
  [tool:read_file] -> ok
  [turn-hook] 回合结束:读文件 package.json → 用了 2 个 step
[assistant] 工具 read_file 返回了:{
  "name": "first-agent",
  "version": "0.1.0",
  "private"…

=== 场景 2:被拦 —— 命令执行类工具,pre 钩子拦截(不进执行)===
[user] 执行命令 whoami
  [blocked-hook] 拦截:run_command {"command":"whoami"} —— 命令执行类工具默认禁止:命令应由人工审批,不交给模型直接执行
  [tool:run_command] -> BLOCKED(命令执行类工具默认禁止:命令应由人工审批,不交给模型直接执行)
  [turn-hook] 回合结束:执行命令 whoami → 用了 2 个 step
[assistant] 工具 run_command 返回了:pre-hook blocked: 命令执行类工具默认禁止:命令应由人工审批,不交给模型直接执行

=== 场景 3:被拦 —— 直接调用 pipeline,危险模式匹配 ===
  [blocked-hook] 拦截:run_command {"command":"rm -rf /"} —— 危险命令模式「rm -rf」被策略拦截
  直接 run_command(rm -rf /) → 被拦:危险命令模式「rm -rf」被策略拦截

=== 场景 4:改写 —— pre 钩子归一化路径后再执行 ===
  [pre-hook] 参数改写:./package.json → package.json
  [post-hook] 记录:read_file → ok(累计 2 次)
  直接 read_file(./package.json) → ok(路径已被改写为 package.json)

=== 纪律的证据:两个账本 ===
· audit —— 真的执行了的工具调用(post 钩子记账):
   - read_file path=package.json → ok
   - read_file path=package.json → ok
· blocked —— 被拦下来的调用(ToolBlocked 钩子记账):
   - run_command command=whoami → 命令执行类工具默认禁止:命令应由人工审批,不交给模型直接执行
   - run_command command=rm -rf / → 危险命令模式「rm -rf」被策略拦截

四个场景,逐行拆。场景 1 是放行:read_file 正常执行,post 钩子每次记一笔。场景 2 最关键:mock 提议 run_command whoami,pre 钩子拦下(BLOCKED),工具 execute 没跑,拦截被 ToolBlocked 记账,loop 照常继续、模型读到「被拒」后总结收尾——这行就是「纪律由工具强制执行」的现场,不是模型自觉。场景 3 是直接调 pipeline 测危险模式。场景 4 证明 pre 不只拦还能改:./package.json 被改写成 package.json,改写后的参数进了 audit 账本。

自测也跑一遍:

$ node step7-hooks/test.js

✅ ① pre 钩子拦截时:工具不执行,且调用被记录为拦截
✅ ② post 钩子在每次工具执行后触发
✅ ③ 钩子可卸载;未匹配的工具调用不受影响
✅ ④ 被拦调用以 toolResult 回写 loop 历史(模型能读到「被拒」)

全部通过:pre 拦得住、post 记得到、钩子可装卸——纪律由工具强制执行。

test.js 里的哨兵手法值得抄:fragileExecuted 是外部状态哨兵,fragile 一旦 execute 就置 true。断言它还是 false,证明「被拦截时工具 execute 绝不能被调用」——不是返回失败,是根本没进执行。

想换真实模型?照工程 README 设 OPENAI_BASE_URL / OPENAI_API_KEY / OPENAI_MODEL 三个环境变量就自动切到 llm/real.js,mock/real 同一个 complete(messages, tools) 签名,换 provider 一行改。真实 API 我本机没跑(无 key),标「待核实」。

4. 两个核心设计:事件发布与钩子注册表

拆开看,这套东西就两个设计,各管一摊。

设计一:事件发布——loop 在关键节点广播。 广播点分两级:回合级(TurnStart / TurnEnd)和工具级(PreToolUse / PostToolUse / ToolBlocked)。先给一张定位图,把广播点标出来。

toolCall

deny

allow / 改写后 args

text

turn 开始

TurnStart 广播
回合级:可观测 / 打点

step:模型请求

模型返回

PreToolUse 广播
工具执行前:拦 / 审批 / 改写输入

ToolBlocked 广播
被拦记账

execute
工具 body

PostToolUse 广播
工具执行后:记录 / 校验 / 改写结果

toolResult 回写

TurnEnd 广播
回合级:追踪 / 计数

turn 结束

图里一个细节值得单独说:为什么广播点放在流水线和 turn 的边界,而不是 execute 里?因为 execute 是工具 body,是「干活」的地方;纪律要加在「干活之前」和「干活之后」。loop 完全不感知钩子,是这套设计最顺的地方——纪律加在流水线上,loop 一行不用改。

设计二:钩子注册表——策略是外挂的。 hook(eventName, fn) 注册、返回卸载函数;emit 遍历处理函数,任何一个返回 { allow: false } 就短路。两个要点:

  • 拦截是短路,改写是透传。 拦截是 allow/deny 二元,任何一个钩子返回 deny 就 fail-fast;改写不一样,钩子返回 { allow: true, args },emit 要把它透传出去。这个点我踩过坑:一开始 emit 只返回 allow/deny,pre 钩子想改参数时 args 被丢掉,流水线读到的永远是原始参数。修正:emit 收集最后一个 args,折进返回值,流水线再 pre.args ?? toolCall.arguments。这是个很容易漏的设计——「拦截」是二元,「改写」需要额外通道。
  • 卸载是必需。 hook() 返回卸载函数,策略可以装上也可以拆下。test ③ 固化了这个行为。

最后说「复用审批三档」。safetyPreHook 里有两层判断,都是系列先导篇第 3 篇审批三档的翻版:危险模式清单是 denylist 一票否决;「命令执行类工具默认禁止」是把 run_command 挂到 danger 档——命令该由人工审批,不交给模型直接执行。区别只在:第 3 篇的 approve 是硬编码进流水线,这里策略是外挂的,要改拦什么,改函数就行,流水线一行没动。

还有一条设计纪律必须讲:被拦的结果必须回写 loop 历史。 若被拦只返回、不回写 toolResult,mock 这种确定性模型下一步会再提同一个 run_command,一路撞满 maxTurns=10 停机。把被拦作为 { role:'toolResult', content: error } 回写(与第 3 篇的 deny 回写同源),模型读到「被拒」就改口。test ④ 专门固化了这个行为——我猜你踩坑时最可能漏掉的就是这条。

demo 是教学最小版,有几处简化,真实工程得补,我逐条说。第一,emit 是同步短路:一个钩子 deny 就停,异常也不隔离;真实 harness 让钩子链按注册顺序逐个 await,每个钩子 try/catch 包住,一个抛错不影响后续钩子与流水线主体,还带超时防某个钩子卡死 loop。第二,我只挂了 5 个事件点,真实工程全生命周期都可挂——会话开始/结束、用户输入提交、每次模型回复、工具调用前后、配置加载后,Claude Code 就有 SessionStart/SessionEnd/UserPromptSubmit/Stop 等 20+ 个。第三,注册方式:我在代码里 hook() 注册、内存态重启即失,真实 harness 用配置驱动,从 .claude/settings.json 的 hooks 段加载钩子定义,运行时不用改代码。第四,allow/deny/args 返回形状是我自定义的约定,真实工程会定标准事件协议,像 PreToolUse 事件对象含 input 可改、决定 allow/deny,第三方钩子按协议接入。第五,我用钩子模拟了先导篇第 3 篇的审批语义,真实工程把两者拆开——审批走审批服务(一次 fail-closed,管「该不该做」),钩子走扩展点(管「接进来干什么」),叠加使用、互不取代。

5. 对照 dsh:六阶段流水线的 pre/post 最小投影

第四节那套东西,对照 dsh 的六阶段流水线,一眼看穿你写的是什么。

模型发起工具调用

pre-execute waterfall
你的 emit('PreToolUse') 位置

审批询问 + 单调守卫
fail-closed

tools/execute
timeout / retry · 工具 body

post-execute waterfall
你的 emit('PostToolUse') 位置

finalizeContent + tools/result

紫色两道口,就是你 emit('PreToolUse')emit('PostToolUse') 的位置。dsh 六阶段里,你的 pre 口对应 prepareExecution 的 pre-execute waterfall(拦截、审批、守卫挂这),你的 post 口对应 postExecute 的 post-execute waterfall(接受、阻断、替换、附加上下文),你的 execute 对应中间那两段。

我压成一句:你的钩子注册表,是 dsh 六阶段流水线 pre/post 两道口的最小投影。 你只实现了「广播 + 注册」,dsh 在这两道口之间还塞了审批询问、单调守卫、三个瀑布、finalizeContent——但位置和意图一模一样。这就是「白给的架构能力」:你没抄 dsh 的实现,抄的是它的站位。

这也回应了我在《拆三家》下的落点:工具流水线最少要有 pre 和 post 两道口。只有 pre 没有 post:拦得住坏事,但「到底发生了什么」没人记账,出了事没有现场。只有 post 没有 pre:只能事后发现事故,拦不住。两道口都有:事前能拦、事后能记——纪律闭环。Claude Code 的 PreToolUse/PostToolUse、dsh 的 pre/post 两个 waterfall,都至少保留这两道口,就是这个原因。

一个小差异,诚实说。Claude Code 把「工具失败」单独拆成 PostToolUseFailure 事件;我的最小版偷懒了——execute 抛错时也走 PostToolUse,只是 result.ok=false。想学 Claude Code 拆开,加一个 PostToolUseFailure 事件就行,广播点的位置我已经留了。

呼应一句旧文。在《复杂软件系统的 Vibe Coding 实践三:工具配置与迭代收尾》里,我把 hook 用在 TDD 上:PreToolUse 拦「没先写失败测试就写生产代码」,PostToolUse 写完代码自动跑测试。那是在 Claude Code 的配置里用的现成机制——当时我是用户,现在你自己造出来了同一个机制。Claude Code 的钩子是产品功能,你的钩子是你 harness 里的接缝:前者给你配,后者你来定。

6. 结论:钩子=把纪律写进工具,AI 不记得你记得

回顾这一篇:给 harness 加了事件发布 + 钩子注册表,把写死的审批改造成可编程的钩子,跑通了四条真实行为——pre 拦得住、post 记得到、钩子可装卸、被拦能回写。

钩子把「纪律」从「让 AI 自觉」变成「由工具强制执行」。 skill 和 rule 是建议,模型可以不听;hook 是脚本,挂上就一定执行。策略写在 hook 里,只要 hook 在,它就永远生效——不依赖模型当次有没有「想起来」。

流水线可以短,不能没有。 最少要有 pre 和 post 两道口:pre 管拦截、审批、改写输入;post 管记录、校验、改写结果。你的 harness 已经长到第七格,子代理、技能与规则、钩子都装上了。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

luckystar513~

感谢你的认可!希望对您有用~

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值