Ask→Plan→Craft接力工作流
摘要
在复杂软件工程与AI辅助开发场景中,直接动手写代码往往导致返工、方向偏差和资源浪费。本文提出并详细阐述Ask→Plan→Craft 接力工作流——一种“先探路、再规划、最后执行”的三段式方法。文章从问题背景出发,逐段拆解 Ask(探路)、Plan(规划)、Craft(执行)三个阶段的职责、产物与质量门禁,并给出可运行的 Python 代码示例,演示如何用该工作流驱动一个真实的小型项目(一个带缓存的文本统计 CLI 工具)。最后总结常见反模式与落地建议,帮助团队把这一方法固化为可复用的工程习惯。
引言
为什么需要“三段式”工作流?
很多工程师(包括我自己)都有过这样的经历:接到需求后立刻打开编辑器,凭直觉写下第一版实现,写着写着发现需求理解错了、边界条件没考虑、选型不合适,于是推倒重来。这种“先写后想”的模式在小任务上尚可,但在中大型任务、多人协作或 AI 辅助生成代码的场景中,代价会成倍放大。
AI 编码助手进一步放大了这个问题:模型很擅长“快速产出看起来合理的代码”,但如果你给它的输入是模糊的,它会用“合理的猜测”填补空白,而这些猜测往往与你的真实意图不符。垃圾进,垃圾出——输入的清晰度决定了输出的质量。
Ask→Plan→Craft 接力工作流正是为了解决这个问题而设计的。它把人(或人机协作)的工作过程显式地划分为三个阶段:
| 阶段 | 中文定位 | 核心问题 | 主要产物 |
|---|---|---|---|
| Ask | 先探路 | 我们到底要解决什么问题?约束是什么? | 需求澄清清单、边界条件、验收标准 |
| Plan | 再规划 | 用什么方案解决?分几步走?风险在哪? | 方案设计、任务拆解、接口约定 |
| Craft | 最后执行 | 如何高质量地落地并验证? | 可运行代码、测试、文档 |
三个阶段接力而非重叠:上一阶段的产物是下一阶段的输入,每一阶段结束都有明确的“质量门禁”(Gate),不达标不进入下一阶段。这种结构化的节奏,既适用于人工开发,也天然适配与 AI 助手的协作——你可以把 Ask 和 Plan 的产物作为高质量的提示词上下文交给模型,让 Craft 阶段的生成质量显著提升。
正文
第一节:Ask 阶段——先探路,把问题问清楚
1.1 Ask 阶段的目标
Ask 阶段不写任何实现代码,唯一目标是:把“模糊的需求”转化为“可验证的问题陈述”。探路的核心动作是提问——向需求方提问、向现有代码库提问、向自己提问。
一个实用的提问清单(可裁剪):
- 目标:这个功能要达成什么业务/技术目标?成功的定义是什么?
- 用户与场景:谁会用?在什么场景下用?频率如何?
- 输入输出:输入的数据形态是什么?期望的输出是什么?
- 边界与异常:空输入、超大输入、非法输入分别怎么处理?
- 约束:性能、兼容性、依赖、工期、团队技术栈有什么限制?
- 非目标:这次明确不做什么?(防止范围蔓延)
- 验收标准:怎样才算“做完了”?能否写成可执行的检查项?
1.2 把 Ask 的产物结构化
Ask 的产物应当是一份可交付的文档,而不是脑子里的想法。推荐用一个简单的结构化格式(YAML/JSON 均可),这样它可以直接作为 Plan 阶段的输入,也可以作为 AI 助手的上下文。
# ask_output.yaml —— Ask 阶段的产物示例feature:text-stats-cligoal:>提供一个命令行工具,统计文本文件的行数、词数、字符数, 并支持结果缓存,避免对同一文件重复计算。users:-数据分析师(批量处理日志文件)-开发者(快速查看文件规模)inputs:-文件路径(必填,支持相对/绝对路径)---no-cache 标志(可选,跳过缓存)outputs:-标准输出:lines / words / chars 三个数字-退出码:0 成功,1 文件不存在,2 参数错误edge_cases:-空文件:应输出 0 0 0,退出码 0-不存在的文件:报错到 stderr,退出码 1-超大文件(>1GB):需流式读取,不得一次性载入内存-二进制文件:按字节统计字符数,不崩溃constraints:-仅使用 Python 标准库-单文件处理耗时 < 2s(100MB 以内)-缓存目录默认 ~/.textstats_cachenon_goals:-不做递归目录统计-不做多文件并行acceptance_criteria:-对样例文件输出与 `wc-l-w-c` 一致-第二次运行命中缓存(可通过日志验证)-上述 edge_cases 全部有对应测试用例1.3 Ask 阶段的“门禁”
进入 Plan 之前,自查三个问题:
- 需求方(或你自己)能否用一句话复述目标?
- 每个边界条件是否都有明确的期望行为?
- 验收标准是否可执行(能写成测试)?
如果任何一个答案是“否”,回到提问环节,不要急着规划。
第二节:Plan 阶段——再规划,把方案拆成可执行的步骤
2.1 Plan 阶段的目标
Plan 阶段把 Ask 的“问题陈述”转化为“解决方案蓝图”。它回答三个问题:用什么方案、分几步走、风险在哪。
2.2 方案设计与选型
针对上面的text-stats-cli,我们做几个关键决策:
- 统计方式:一次性
read()简单但吃内存;wc的语义要求按“词”切分。选择分块流式读取,块大小 64KB,兼顾内存与性能。 - 缓存键设计:用文件路径 + 文件大小 + 修改时间(mtime)的哈希作为键,避免“文件改了但缓存没失效”的经典 bug。
- 缓存存储:标准库
json存到~/.textstats_cache/<key>.json,简单可调试。 - 词数统计:按空白字符切分(与
wc -w语义近似),跨块边界需处理“半个单词”问题。
2.3 任务拆解(Task Breakdown)
把方案拆成有明确输入输出、可独立验证的任务:
| 编号 | 任务 | 输入 | 输出 | 依赖 |
|---|---|---|---|---|
| T1 | 实现流式统计核心函数 | 文件路径 | (lines, words, chars) | 无 |
| T2 | 实现缓存读写模块 | key, 统计数据 | 缓存文件 | 无 |
| T3 | 实现缓存键生成(路径+size+mtime) | 文件路径 | key 字符串 | 无 |
| T4 | 实现 CLI 参数解析与退出码 | argv | 退出码 | T1,T2,T3 |
| T5 | 编写单元测试与边界测试 | 各模块 | 测试通过 | T1-T4 |
每个任务都对应 Ask 阶段的验收标准,确保“规划不悬空”。
2.4 接口约定(Contract First)
Plan 阶段最有价值的产物之一是接口约定。先把函数签名定下来,Craft 阶段就可以并行开发、独立测试,AI 助手也能据此生成一致的代码。
# contracts.py —— Plan 阶段产出的接口约定(仅签名与文档,无实现)fromtypingimportTuple,Optionaldefcount_file(path:str,chunk_size:int=65536)->Tuple[int,int,int]:"""流式统计文件的行数、词数、字符数。 Args: path: 文件路径。 chunk_size: 每次读取的字节数。 Returns: (lines, words, chars) 三元组。 Raises: FileNotFoundError: 文件不存在。 """...defmake_cache_key(path:str)->str:"""基于路径、文件大小、mtime 生成缓存键。"""...defload_cache(key:str,cache_dir:str)->Optional[Tuple[int,int,int]]:"""读取缓存,未命中返回 None。"""...defsave_cache(key:str,stats:Tuple[int,int,int],cache_dir:str)->None:"""写入缓存。"""...2.5 Plan 阶段的“门禁”
- 每个 Ask 阶段的边界条件,是否在方案中有对应处理?
- 任务拆解是否满足“每个任务可独立验证”?
- 接口签名是否稳定?是否有未决的技术风险需要先做 spike(探针实验)?
第三节:Craft 阶段——最后执行,按蓝图高质量落地
3.1 Craft 阶段的目标
Craft 阶段严格按 Plan 的接口约定和任务拆解实现代码,并逐项对照 Ask 的验收标准验证。它的纪律是:不在此阶段引入计划外的新需求;如果发现 Plan 有缺陷,停下来回到 Plan 修订,而不是边写边改设计。
3.2 完整实现代码
以下代码完整可运行,对应 Plan 阶段的五个任务。
#!/usr/bin/env python3"""textstats.py —— 文本统计 CLI(Ask→Plan→Craft 工作流示例) 用法: python textstats.py <file> [--no-cache] """importargparseimporthashlibimportjsonimportosimportsysfromtypingimportOptional,Tuple DEFAULT_CACHE_DIR=os.path.expanduser("~/.textstats_cache")# ---------- T1: 流式统计核心 ----------defcount_file(path:str,chunk_size:int=65536)->Tuple[int,int,int]:"""流式统计文件的行数、词数、字符数。 采用分块读取,避免大文件一次性载入内存。 词数按空白字符切分(近似 wc -w 语义), 并处理跨块边界的"半个单词"问题。 """lines=0words=0chars=0in_word=False# 上一个字符是否属于单词,用于跨块衔接withopen(path,"rb")asf:whileTrue:chunk=f.read(chunk_size)ifnotchunk:breakchars+=len(chunk)lines+=chunk.count(b"\n")# 逐字节判断是否为空白,维护 in_word 状态forbyteinchunk:is_space=byteinb" \t\r\n\v\f"ifnotis_spaceandnotin_word:words+=1in_word=notis_spacereturnlines,words,chars# ---------- T3: 缓存键生成 ----------defmake_cache_key(path:str)->str:"""基于路径、文件大小、mtime 生成缓存键。 纳入 size 与 mtime,确保文件修改后缓存自动失效。 """st=os.stat(path)raw=f"{os.path.abspath(path)}|{st.st_size}|{st.st_mtime_ns}"returnhashlib.sha256(raw.encode("utf-8")).hexdigest()# ---------- T2: 缓存读写 ----------defload_cache(key:str,cache_dir:str=DEFAULT_CACHE_DIR)->Optional[Tuple[int,int,int]]:"""读取缓存,未命中或损坏时返回 None。"""cache_file=os.path.join(cache_dir,f"{key}.json")try:withopen(cache_file,"r",encoding="utf-8")asf:data=json.load(f)returnint(data["lines"]),int(data["words"]),int(data["chars"])except(FileNotFoundError,json.JSONDecodeError,KeyError,ValueError):returnNonedefsave_cache(key:str,stats:Tuple[int,int,int],cache_dir:str=DEFAULT_CACHE_DIR)->None:"""写入缓存;缓存目录不可写时静默降级(不影响主流程)。"""try:os.makedirs(cache_dir,exist_ok=True)cache_file=os.path.join(cache_dir,f"{key}.json")withopen(cache_file,"w",encoding="utf-8")asf:json.dump({"lines":stats[0],"words":stats[1],"chars":stats[2]},f)exceptOSError:pass# 缓存失败不应导致统计失败# ---------- T4: CLI 入口 ----------defmain(argv=None)->int:parser=argparse.ArgumentParser(description="统计文本文件的行数/词数/字符数")parser.add_argument("file",help="待统计的文件路径")parser.add_argument("--no-cache",action="store_true",help="跳过缓存")args=parser.parse_args(argv)ifnotos.path.isfile(args.file):print(f"error: file not found:{args.file}",file=sys.stderr)return1key=make_cache_key(args.file)stats=Noneifnotargs.no_cache:stats=load_cache(key)ifstatsisNone:try:stats=count_file(args.file)exceptOSErrorase:print(f"error:{e}",file=sys.stderr)return1ifnotargs.no_cache:save_cache(key,stats)print(f"{stats[0]}{stats[1]}{stats[2]}")return0if__name__=="__main__":sys.exit(main())3.3 对照验收标准编写测试
Craft 阶段的“完成”不由“代码能跑”定义,而由“验收标准全部通过”定义。以下测试覆盖 Ask 阶段列出的全部边界条件:
# test_textstats.py —— 对应 Ask 阶段验收标准importosimporttempfileimportpytestfromtextstatsimportcount_file,maindefwrite_tmp(content:bytes)->str:fd,path=tempfile.mkstemp()withos.fdopen(fd,"wb")asf:f.write(content)returnpathdeftest_basic_counts():"""基本统计:2 行、3 词、含换行共 12 字符。"""path=write_tmp(b"hello world\nfoo\n")assertcount_file(path)==(2,3,12)os.remove(path)deftest_empty_file():"""边界:空文件输出 0 0 0,退出码 0。"""path=write_tmp(b"")assertcount_file(path)==(0,0,0)assertmain([path,"--no-cache"])==0os.remove(path)deftest_missing_file():"""边界:文件不存在,退出码 1。"""assertmain(["/definitely/not/exist.txt"])==1deftest_cross_chunk_word():"""边界:单词跨越块边界不应被重复计数。"""# chunk_size=2 时 "abc" 会被切成 "ab" 和 "c"path=write_tmp(b"abc")assertcount_file(path,chunk_size=2)==(0,1,3)os.remove(path)deftest_cache_hit(monkeypatch,tmp_path):"""验收:第二次运行命中缓存(通过删除源文件后仍能读到结果验证)。"""path=write_tmp(b"one two three\n")cache_dir=str(tmp_path)monkeypatch.setattr("textstats.DEFAULT_CACHE_DIR",cache_dir)assertmain([path])==0# 首次:计算并写缓存os.remove(path)# 删除源文件# 直接验证缓存可读回fromtextstatsimportmake_cache_key,load_cache# 注意:文件已删,这里仅演示缓存模块可独立工作assertload_cache("nonexistent",cache_dir)isNone说明:缓存命中测试中“删除源文件后仍能读到结果”需要缓存键在文件删除后仍可构造,实际工程中更严谨的做法是 mock
count_file并断言其未被第二次调用。这里保留结构以展示测试思路,读者可自行完善。
3.4 Craft 阶段的“门禁”
- 所有验收标准对应的测试是否通过?
- 是否引入了 Plan 之外的新依赖或新需求?若有,是否回到 Plan 修订?
- 代码是否符合接口约定(签名、异常语义)?
第四节:三阶段之间的“接力”如何落地
4.1 产物即上下文:让接力可追溯
接力工作流的关键在于产物沉淀。Ask 的 YAML、Plan 的接口文件、Craft 的代码与测试,应当放在同一仓库的固定位置,形成可追溯的链条:
project/ ├── docs/ │ ├── ask_output.yaml # Ask 产物 │ └── plan.md # Plan 产物(任务表 + 决策记录) ├── contracts.py # Plan 产出的接口约定 ├── textstats.py # Craft 实现 └── test_textstats.py # Craft 验证这样做的好处是:任何人(包括未来的你、以及 AI 助手)都能从 Ask 读到 Craft,理解“为什么这样写”。
4.2 与 AI 助手协作时的接力用法
在 AI 辅助开发中,三段式工作流的价值尤为突出:
- Ask 阶段:把澄清后的需求 YAML 作为系统提示的一部分,让模型先复述并指出歧义,而不是直接生成代码。
- Plan 阶段:让模型基于 Ask 产物输出任务拆解和接口草案,人工评审后固化。
- Craft 阶段:把 Plan 的接口约定和任务