第 2 章 运行与验证循环

学习目标

  • 解释本地运行、开发服务器、端口之间的关系,准备好首次拿到的仓库,使其能够运行。
  • 区分测试、类型检查、lint、构建各自能发现和会遗漏的问题。
  • 从失败输出中读出什么、在哪里、为什么出错,选出应最先查看的错误。
  • 逐个检验假设,缩小失败原因范围。
  • 通过浏览器检查和证据要求验证“完成”声明,明确说明未能验证的内容。

引言

“修改已经完成,所有测试均已通过。”与编程智能体一起工作时,一天之内常常会看到好几次这句话。然而,单凭这句话,无法知道测试是否真的执行过、所执行的测试是否涉及这次变更,以及界面上的问题是否也已经消失。

一位业余项目运营者相信了智能体的报告,将应用部署出去,结果第二天早晨就收到了用户咨询。测试确实全部通过了,只不过这些测试仅涉及计算函数,而所修复的bug却出现在界面上。这是一个只要打开浏览器检查一次就能发现的问题。

本章讲解如何把“已经做好了”这句话,转变成“确实做好的证据”。我们从应用究竟在哪里运行讲起,依次学习如何准备本地运行、各类检查工具分别承担什么角色、如何阅读错误信息,以及如何逐步缩小原因范围。随后再讨论让验证循环不必依靠人时刻记住也能执行的机制,以及浏览器检查、索要证据和整理上下文的方法。

核心概念

应用运行在哪里:浏览器、服务器、存储

Web应用通常在三个地方工作。用户的浏览器绘制界面并接收按钮点击;服务器接收浏览器请求,计算并返回响应;存储(数据库或文件)保存刷新后也必须保留的数据。以餐厅为例,浏览器是前厅,服务器是厨房,数据库是仓库。

区分它们很重要,因为错误出现的位置各不相同。

问题位置 错误显示位置
浏览器(界面代码) 浏览器开发者工具的控制台
浏览器与服务器之间(请求、响应) 开发者工具的网络标签页、服务器输出
服务器代码 启动开发服务器的终端;部署后则是服务器日志
数据库连接 服务器输出中的连接错误(例如表示连接被拒绝的ECONNREFUSED,可能是数据库未启动或地址、端口不正确)

“界面提示已保存”与“数据确实保存”是不同的事实。保存结果要通过刷新或在另一台设备重新打开来确认。

本地运行:开发服务器与端口

本地运行,是指将应用发布到互联网之前,先在自己的电脑上运行并试用,就像餐厅正式开业前,先邀请家人来试吃。负责启动应用的程序叫作开发服务器。开发服务器会打开一个带有编号的入口,也就是端口(port),并等待请求。http://localhost:3000的意思就是“自己电脑上的3000号入口”。

启动开发服务器时,如果遇到EADDRINUSE(address already in use)错误,就表示这个端口已经被另一个进程使用。常见情况是,之前启动的开发服务器仍然在另一个终端里运行。应对方法是关闭那个进程,或者改用其他端口启动。关闭时,要先确认占用端口的进程编号(PID)(macOS与Linux中可用lsof -i :3000),再用kill <PID>只结束那个进程。根据名称模式一次性关闭多个进程的命令,可能连无关的程序也一起关闭,因此很危险。这个错误通常属于端口占用问题,重新安装并不能解决。少数情况下,也可能是代码或配置让同一个服务器被启动了两次。

准备首次拿到的仓库

初次拿到别人创建的仓库,或智能体创建的仓库时,人们很想立刻输入npm run dev。然而,应用只有在完成几项准备后才能运行。按顺序逐项确认,可以减少无目的摸索的时间。

  1. 阅读README与指南文件。 运行命令与前置准备通常写在这里。
  2. 对齐运行时版本。 仓库有.nvmrc等版本文件,就用指定版本的Node.js。
  3. 按lock文件安装。 如果仓库中有package-lock.json,就使用npm ci,严格按照该文件记录的版本进行安装。npm文档说明,缺少lock文件时,这条命令无法运行;如果lock文件的内容与package.json不一致,它会报告错误并停止,而不会为了消除差异自动修改文件。此外,执行这条命令既不会修改package.json,也不会修改lock文件本身。
  4. 补齐环境变量。 根据.env.example确认所需名称,在.env.local等本地文件中填入测试值。
  5. 准备数据库。 有迁移命令就运行,以创建表。
  6. 启动开发服务器,亲自打开一个核心页面。

跳过这些步骤,出现的错误通常有规律:缺少环境变量就回看第4步,Cannot find module回看第3步,数据库连接被拒绝回看第5步。

检查工具地图

自动检查代码的工具有多种,各自发现的问题不同。

工具 比喻 能发现的问题 不能发现的问题
测试 试吃团评价口味 给定输入是否得到预期结果 未编写测试的行为
类型检查 核对设计图尺寸 函数参数的类型和数量、使用不存在的属性 值的形式正确但计算逻辑错误
lint 厨房卫生检查表 未使用变量、危险代码模式、违反代码规范 功能是否按需求运行
构建 开业前的最终组装 仅打包为部署版本时才出现的错误 运行中的逻辑错误

在TypeScript项目中,可以使用tsc --noEmit这类命令,仅执行类型检查,而不输出编译产物。TypeScript文档说明,noEmit这一选项的作用就是不输出编译结果,只将TypeScript用作类型检查器。例如,为一个函数新增一个必填参数后,运行一次类型检查,就能够找出调用方式不再符合要求的位置。不过,对于通过any等方式绕过类型检查的调用,这种检查仍有可能遗漏。

关键在于每种工具的“通过”含义不同。测试通过,不代表未覆盖的界面正确;类型检查通过,也不代表折扣计算正确。因此应按改动性质选择检查,从轻量检查开始运行。

拆解错误信息

错误信息很长,并不是为了吓人。通常会告诉你三件事。

  1. 什么:错误类型(TypeError、失败测试的名称)。
  2. 在哪里:文件名与行号。
  3. 为什么:预期值与实际值,或缺少了什么。
TypeError: Cannot read properties of undefined (reading 'filter')
    at ClassSchedule (src/components/ClassSchedule.tsx:31:18)
    at renderWithHooks (node_modules/react-dom/cjs/react-dom.development.js:15486:18)

第一行的含义是:“试图对一个尚未取得值(undefined)的对象使用filter。”下面的堆栈跟踪(stack trace)按照由内向外的顺序,列出发生错误时所经过的函数。位于node_modules中的行属于第三方编写的依赖库,位于src/中的行才属于本项目代码。通常应当先打开本项目代码中位置最靠上的那一行;在这个例子中,就是ClassSchedule.tsx的第31行。这里很可能是在数据尚未加载完成之前,就尝试绘制列表。

错误一下涌出几十条时,先看第一个错误。一个模块找不到,所有使用它的地方都可能产生连锁错误。修好最初原因,后面大多数错误会一起消失。

最可靠地说明命令成功或失败的,是退出码(exit code),而非提示语。0表示成功,非0表示失败。即使满屏警告,退出码为0就代表命令成功;即使最后一行没有报错信息,非0也代表命令失败。终端用echo $?查看上一条命令的退出码。程序通常把结果写入标准输出(stdout),把错误写入标准错误(stderr)。输出太长而要完整交给智能体时,可用npm test > test.log 2>&1将标准输出写入文件(>),并把标准错误合并到同一处(2>&1),避免遗漏。

根据失败输出缩小原因范围

查找原因的工作,就像侦探办案:先收集线索,再逐个核查嫌疑人,不会仅凭未经确认的猜测就逮捕一个人。

  1. 复现。 确认按照同样的步骤,是否会出现同样的失败。如果不能复现,也就无法知道问题是否已经修好。
  2. 区分失败发生的阶段。 安装、启动、构建、运行,失败发生在哪个阶段,决定了应该查看什么位置。
  3. 选择最先出现的相关错误。 暂时将后续连锁错误放在一旁。
  4. 提出一个假设。 将它写成能够确认的句子,例如“这个import路径已经改变”。
  5. 只验证这个假设。 如果同时修改多个地方,就无法知道究竟哪一项修改起了作用。
  6. 修改之后,再次运行相同的检查。 使用最初失败的那一条命令确认结果。

向智能体反馈失败报错时,应当附上完整输出,并明确要求:“修复根本原因,不要掩盖错误。”Claude Code最佳实践同样建议,将构建失败交给智能体处理时,应附上原始错误信息,要求它确认修复后的成功结果,同时强调不要压制错误,而要解决根本原因。掩盖错误的典型做法包括:添加关闭类型检查的注释、跳过测试,以及把预期值改成与实际输出相同的值。如果预期值本来就符合需求,那么出错的是代码,应当修复代码。

验证循环与强制机制

验证循环是修改、运行检查、读取结果,重复直到检查通过的流程。把检查命令告诉智能体,它便能自行循环,但人每次都要说,智能体偶尔也会忘。因此要把循环的一部分固定为机制。

机制 作用 例子
任务指令 告知本次任务应运行的检查 “实现后运行npm test并展示输出”
指南文件 告知所有任务共用的检查命令 “任务结束后运行npm run check”
钩子 在规定时点无例外执行检查 每次修改文件后运行格式化工具,结束前运行检查脚本
CI必需检查 阻止未通过检查的改动被合并 每次拉取请求都测试,失败时禁用合并按钮

Claude Code文档解释,钩子与指南文件不同,会确定性地执行,保证相应行为发生。例如为PostToolUse事件设置Edit|Write条件的钩子,会在智能体每次通过这些编辑工具修改文件后运行格式化工具。它捕捉不到shell命令修改的文件,因此结束时还需再检查一次以免遗漏。把钩子写入项目的.claude/settings.json并提交,团队即可共享。交互会话需先批准信任该目录,钩子才会执行,也能通过设置关闭。Stop钩子在智能体准备结束回答时运行检查脚本,失败则让任务继续。但文档指出,若没有工具调用而连续阻止八次,下一次便会允许结束,因此不能无条件保证检查通过。

在GitHub的保护分支(protected branch)设置中,开启“合并前必须通过状态检查”后,只有指定的必需检查以成功(或跳过、中立)状态结束,改动才能合并到这个分支。即使审查者一时忘记检查,这道机制也能阻止存在问题的改动被合入。默认设置下,管理员不受这条规则约束;如果希望规则无例外地适用,还需要开启禁止绕过的选项。CI(持续集成,即每次变更都会自动运行检查的系统)中,应使用npm ci安装依赖。npm文档也明确说明,这条命令面向CI等自动化环境。

在浏览器中像用户一样检查

修改界面的任务,必须在浏览器亲自检查才算结束。Anthropic的长时间运行智能体实验也发现,智能体常在未充分测试功能时将其标为完成;明确要求用浏览器自动化像真实用户一样测试后,它便发现了仅读代码会遗漏的bug。Claude Code最佳实践也建议,对界面修改截取结果,与设计稿比较并修正差异。

亲自检查时查看以下内容。

  • 完整执行点击、输入流程,刷新后结果是否保留
  • 缩窄到手机宽度(例如360px),是否有截断或重叠
  • 空数据、错误输入、响应缓慢等第1章定义的状态
  • 开发者工具的控制台:是否有红色JavaScript错误
  • 开发者工具的网络标签页:请求是否真正发出,响应状态码是什么

状态码用数字概括响应结果。200段表示成功,400段表示请求侧问题(错误输入、无权限、地址不存在),500段表示服务器侧问题。点击按钮无反应时,网络标签页完全没有请求就看界面代码;请求以500失败,就看服务器输出。

区分声明与证据

智能体的“已完成”是声明。证据是执行的命令及原始输出、截图、检查步骤与结果。Claude Code最佳实践建议,不要只让智能体断言成功,而要展示测试输出、执行命令及结果、结果界面等证据。审查证据比重新验证更快,也适用于无人旁观的会话。

好的完成报告,既包含已确认的内容,也包含未能确认的内容。

已执行检查:npm test(42项通过,0项失败),npx tsc --noEmit(无错误)
界面检查:附手机、桌面宽度截图。确认刷新后保存结果保留
未能确认:实际短信发送(测试中仅确认发送记录)

在同一上下文中完成工作的智能体,有可能重复同一个误解,并据此给出“已经确认”的结论。因此,Claude Code文档介绍了一种方法:让处于全新上下文中的子智能体尝试反驳现有结果,从而避免由执行任务的智能体为自己的工作评分。

上下文混乱时重新开始

正如第1章所述,对话的上下文中,也会不断积累失败的尝试。反复修改同一个问题时,堆积着失败方法的上下文,可能再次引出相同的错误。Claude Code最佳实践建议,如果在同一个会话中,对同一问题纠正超过两次仍然出错,就用/clear清空上下文,再用一份更具体、包含此前所学经验的指令重新开始。干净的会话配合更好的指令,几乎总是优于一段不断积累纠正内容的冗长会话。

重新开始之前,要记住两件事。第一,即使清空了对话,磁盘上的代码仍然保持原样。如果失败尝试造成的变更还留在那里,就要先决定是否回退到最后一次经过验证的提交(第3章)。第二,要留下交给新会话的交接备忘。写清楚曾经尝试过什么、什么尝试失败了,以及哪些假设已经排除,才不会再次沿着同一条错误路径前进。

舍不得丢掉对话,可用/compact <指令>围绕指定内容进行摘要。

深入阅读

通过的测试与有用的测试

测试通过这一事实,并不能证明这项测试有用。没有用处的测试,即使代码中存在bug,也照样会通过。典型的情况有以下四种。

无用的测试 表现 核实方法
照抄实现的测试 预期值以与代码相同的方法计算 查看预期值是否独立来自需求
断言过弱 只检查“结果存在” 查看是否比较具体数值或状态
从未失败的测试 修复前也通过 暂时还原修改,看是否失败
按结果改过的测试 失败后改变预期值 在diff中查找测试文件的预期值修改

最有力的确认方法,是先看到测试失败。修复bug时,先编写能够复现问题的测试,并确认它在修复之前确实失败。Claude Code最佳实践也给出了这样的委派示例:描述症状之后,要求智能体“先编写能够复现问题的失败测试,然后再修复”。只有修复前失败、修复后通过的测试,才能证明这次修改确实解决了问题。

另一个类似的陷阱,是批量更新快照测试(保存界面或输出以前的样子,再与当前结果作比较的测试)。如果把所有失败的快照一次性覆盖成“新的基准”,那么有意作出的变化和无意造成的变化,就会一起变成正确答案。更新之前,应逐一检查失败的快照,确认它所反映的变化是否确实是本次任务想要改变的内容。

偶尔失败的测试

未改代码却时而通过、时而失败的测试,叫作不稳定测试(flaky test)。重跑通过后很想直接放过,但大多数都有真实原因。

  1. 测试之间共享状态。 如果使用同一个测试数据库或全局变量,前一项测试留下的数据,就可能破坏后一项测试。单独执行时通过,全部一起执行时却失败,就是典型信号。Playwright文档强调,各项测试必须完全隔离、独立运行,才能提高可复现性,并避免失败接连发生。
  2. 等待方式错误。 如果界面元素需要稍后才出现,却只检查一次它“是否存在”,检查结果就会取决于执行检查的那个瞬间。Playwright的Web优先断言(await expect(locator).toBeVisible())会持续等待并重试,直到满足条件;如果直接使用isVisible()检查,则不会等待,而是立即返回当时的结果。采用固定等待时长也不能解决这个问题:在运行较慢的环境中仍可能失败,在运行较快的环境中又会白白浪费时间。
  3. 依赖外部因素。 结果会随着真实外部API或当前时间而改变。测试中应将响应与时间固定下来。

设置重试次数只能掩盖症状,不能消除原因。CI中偶尔失败的测试,必须取得失败当时的信息,才能修复。因此,Playwright文档建议,分析CI失败时,应使用trace viewer(一种将记录下来的执行过程逐步回溯查看的工具),而不只是视频或截图,并设置在失败测试的第一次重试时记录trace。

自己电脑与其他环境结果不同时

“在我自己的电脑上可以运行啊”,是开发现场流传最久的玩笑。如果结果不同,原因就在两个环境的差异之中。这种差异通常属于下面几种情况之一。

差异 例子 对齐方法
运行时版本 本机与CI的Node.js主版本不同 添加版本文件,让两边使用同一版本
已安装依赖 npm install安装了不同的下级依赖版本 提交lock文件,CI使用npm ci
环境变量 值只存在于本机本地文件 记录所需名称,也加入CI与部署设置
操作系统 macOS的APFS若未特意选择区分大小写格式,则不区分文件名大小写,Linux则区分 import路径大小写与实际文件名完全一致
执行方式 只检查开发服务器,没跑部署构建 部署前也在本地运行构建命令

环境变量尤其容易出现陷阱。根据Next.js文档,原则上应通过.gitignore将.env*.local文件排除在仓库之外;在测试环境(NODE_ENV=test)中,也不会读取.env.local。这样设计是为了让所有人的测试都得到相同结果。因此,如果测试依赖的是自己电脑上.env.local中的值,那么它在其他环境失败,正是正常的结果。

性能也一样。开发服务器与部署构建的代码执行方式不同,因此修改前后要在相同、接近部署的条件下测量,才有可比性。

工作场景

盐面包店:无法保存的上午9点

社区面包店“盐面包店”的预约应用收到顾客反馈:“即使选择了预约时间,也保存不了。”运营者闵经理没有立刻修改,而是先复现症状。选择下午时段可以保存,但选择上午9点时段,即使点击保存按钮也什么都不发生。开发者工具的控制台没有报错,网络标签页却留下了请求已经发出、随后以400响应返回的记录。问题出在请求的内容上,而不是界面上。

闵经理把复现步骤交给智能体,要求:“先增加一个能捕捉这个症状的测试,并展示它在修复之前如何失败。”智能体提出立刻修改格式校验代码,但闵经理拒绝了。没有先看到失败,就无法相信修复后的通过结果。测试果然如预期般失败,输出中显示Expected: "09:00" / Received: "9:00"。时间字符串缺少开头的0,因此被服务器的格式校验拦截了。

智能体修改了生成时间的函数之后,新测试通过了。虽然智能体报告“完成”,闵经理却没有在这里停下来。他运行了全部测试与类型检查,又在浏览器中选择上午9点并保存,随后在网络标签页确认请求以200响应成功,并确认刷新页面后预约仍然存在。直到这时,才创建提交。

正是因为复现了症状,才能编写测试;因为看到了修复前的失败,才能信任这项测试;因为在浏览器中进行了确认,才能说症状确实已经消失。

绿星植物园:面对47条错误

运营会员植物养护应用的“绿星植物园”开发者池工程师,让智能体把分散各处的植物辅助函数整理到src/lib/plants/文件夹中。任务完成后运行npm run build,一下出现了47条错误。

智能体提出了两个方案:一个是在每一行出错的位置添加跳过类型检查的注释,另一个是将整个文件夹整理工作全部回退。池工程师两个都没有接受,因为第一个方案只是掩盖错误,根本原因仍然存在;第二个方案则会连原本要求的整理工作一起丢弃。

池工程师从头开始阅读完整输出。最先出现的错误,是src/app/watering/page.tsx中的Cannot find module '@/lib/plant-utils'。之后的大部分错误,看起来都是因为找不到原先从这个模块导入的函数与类型,于是在每个使用这些函数与类型的文件中接连产生了连锁错误。池工程师只提出一个假设:“仍有文件在使用旧路径进行导入。”随后,他将任务交给智能体,要求它只验证这一个假设。

找出所有导入旧路径 '@/lib/plant-utils' 的文件,改为新路径。
不改其他代码。修改后重跑 npm run build ,展示完整输出。

再次运行构建后,错误减少到了两条。剩下的两条由另一个原因造成:整理过程中,浇水计算函数被修改为额外接收一个季节值,但调用它的两个位置仍然采用原来的方式。池工程师进一步检查,这项修改是否属于原来的任务范围。原本只要求整理文件夹,改变函数的接口已经超出范围。让智能体将函数恢复为原来的接口之后,构建通过了。

池工程师随后运行了类型检查与测试,并在浏览器中打开浇水日程界面,确认日期是否与之前一致。47条错误,其实只有两个原因。正是因为从第一个错误开始,逐个验证假设,才得以既不掩盖错误、也不丢弃工作,而是修好问题。

易错点

  1. “测试通过了,工作就结束了。” 通过看起来像是终点。然而,测试只能确认它所涉及的范围。如果修改了界面,却没有界面测试,那么在完成浏览器检查之前,工作就还没有结束。
  2. “测试失败了,修改预期值就可以。” 只需要改一个数字,似乎很快。然而,如果预期值本来就与需求一致,那么出错的就是代码。当智能体提出修改测试或跳过测试时,正是最需要警惕的时候。
  3. “重新运行后通过,就没有问题了。” 重跑通过会让人安心。然而,偶尔失败通常有共享状态、等待方式错误等真实原因;如果每次都在同一个值上失败,则属于逻辑错误。重新运行不能代替原因分析。
  4. “错误的数量多,就说明问题很多。” 数字很大时,容易让人无从下手。然而,几十条错误往往是由同一个原因接连引发的。应当从最先出现的错误开始看。
  5. “智能体说它很确定。” 充满信心的报告会让人愿意相信。然而,自信并不是证据。应要求它展示命令、输出、界面等证据,并询问还有哪些内容未能确认。
  6. “清空对话,工作也就回到最初的状态。” 新会话让人感觉像重新出发。然而,即使清空上下文,磁盘上对代码作出的变更仍然存在。是否需要回退,必须另外通过Git来决定。

要点速览

主题 应记住的内容
错误位置 界面看控制台,请求看网络标签页,服务器看终端与日志
端口冲突 EADDRINUSE表示端口已被占用,确认PID后只关闭该进程
首次运行 README → 运行时版本 → npm ci → 环境变量 → 迁移 → 运行
检查工具 测试检查行为,类型检查形式,lint检查模式,构建检查部署组装
读错误 什么、在哪里、为什么;自己代码最上面一行、首条错误、退出码
缩小原因 复现 → 区分阶段 → 首条错误 → 一个假设 → 检验 → 重跑同一检查
强制机制 从弱到强:指令 < 指南文件 < 钩子 < CI必需检查
浏览器 完整流程、窄屏、刷新后保留、控制台与状态码
证据 命令、原始输出、截图,以及未能确认的内容
整理上下文 纠正超过两次仍不行,就/clear并改进指令;代码另行回退

查阅官方文档

自测

2-1. 启动开发服务器后出现Error: listen EADDRINUSE: address already in use :::5173。这是什么意思,应如何应对?再举一种应避免的做法。

2-2. 智能体报告“测试失败0项”,打开输出却是Tests: 0 passed, 0 total。为什么不能视为通过?应先怀疑什么原因?

2-3. 咨询表单点击“发送”无反应。应在浏览器开发者工具的控制台、网络标签页分别查看什么?如何根据结果区分问题位置?

2-4. 以下输出中,最先应打开哪个文件、哪一行?为什么?

TypeError: Cannot read properties of null (reading 'price')
    at sumLines (src/checkout/summary.ts:47:12)
    at OrderPanel (src/components/OrderPanel.tsx:18:9)
    at renderWithHooks (node_modules/react-dom/cjs/react-dom.development.js:15486:18)

2-5. 构建命令输出几十行黄色警告,最后一行没有报错信息。确认命令是否成功,最可靠的方法是什么?

2-6. 智能体修复折扣计算bug并添加一个新测试,测试通过。说明一个简单方法,确认该测试是否真正能捕捉这个bug。

2-7. MacBook上正常的应用,在Linux CI中以Module not found: Can't resolve './components/header'失败。实际文件名是Header.tsx。原因是什么,如何修复?

实操练习

练习

围绕失败输出完成一轮循环(45分钟)

  1. 在练习应用的一个函数中故意引入bug(例如求和时遗漏最后一项)。
  2. 先写捕捉该bug的测试,亲自确认失败。
  3. 在纸上从失败输出记录什么、在哪里、为什么出错。
  4. 把完整输出交给智能体,要求“修复原因,不改测试,展示全量测试输出与退出码”。
  5. 暂时还原修复,确认测试再次失败,再恢复修复。

完成标准: 自己写下的原因与智能体修复位置一致,同时保留修复前的失败输出和修复后的通过输出(退出码0),测试文件未被修改。

练习

用机制固定验证(50分钟)

  1. 在指南文件中写一行任务结束时执行的检查命令(例如npm run check)。
  2. 用所用工具的钩子功能,设置每次修改文件后运行格式化工具或lint,并将配置文件提交仓库(Claude Code为.claude/settings.json中的PostToolUse)。
  3. 交给智能体一项小界面修改,确认钩子确实执行的痕迹。
  4. 在浏览器以手机和桌面宽度检查,记录控制台是否有错误、网络请求状态码。
  5. 按“已执行检查、界面检查、未能确认”三栏整理完成报告。

完成标准: 有已提交的钩子设置,也有无需额外指令便运行钩子的记录;三栏完成报告的“未能确认”栏不为空,或说明了为空的理由。