生成式 AI 项目跑通 demo 容易,真正落地到团队协作、持续迭代、稳定部署,卡脖子的往往不是模型效果,而是工程化底子太薄。这两年我带过不少生成式 AI 项目,从 RAG 问答到 Agent 工作流都摸过一遍,发现一个规律:凡是能顺利从原型走向生产的,几乎都有一套清晰、标准化的目录结构在撑着;凡是目录乱成一锅粥的,哪怕模型选得再好,后期维护和扩展也一定让你头疼。
这篇文章我想围绕“生成式 AI 项目的工程化范式”这个主题,重点拆解标准化目录结构怎么设计、为什么这样设计、以及落地时容易踩的坑。内容偏实践,适合正在做生成式 AI 应用、想把项目从“能跑”变成“好维护”的开发者参考。
1. 生成式 AI 项目的整体设计思路拆解
1.1 为什么标准化目录结构是工程化的地基
很多人觉得目录结构只是文件摆放问题,随便整整就行。但真正做过生成式 AI 项目的人会明白,这类项目跟前端后端项目有个本质区别:它的核心资产不仅仅是代码,还包括数据、Prompt、模型权重、评测结果、日志、Agent 配置等等。这些资产类型多、更新频繁、相互依赖复杂,如果没有一个清晰的目录来承接,项目很快就会失控。
我接手过一个典型的反面案例:项目里数据散落在各个文件夹,Prompt 直接硬编码在业务代码里,模型权重放在网盘靠人工同步,评测脚本和训练脚本混在一起。结果就是,每次调 Prompt 都要全局搜索替换,换模型要改好几个文件,新人入职两周还在摸索“文件都放哪了”。这不是个例,而是生成式 AI 项目在没有工程化约束时的常态。
标准化目录结构的核心价值,是用一套约定俗成的规则,把项目的“资产”和“流程”显式地组织起来。它解决的不是“文件放哪好看”的问题,而是几个更底层的需求:
- 可复现性:同样的输入,在任何一台机器上 clone 项目,都能跑出一致的结果。
- 可协作性:每个角色(算法、后端、数据标注、运维)都知道自己该碰哪些目录、不该碰哪些目录。
- 可演进性:换模型、换数据、换 Prompt 时,改动被局部化,不会牵一发动全身。
真正理解这一点,你才会明白目录结构不是形式主义,而是工程化范式的具象化表达。
1.2 核心设计原则:按生命周期划分目录
那标准化目录到底怎么设计才合理?我的经验是:按资产的生命周期划分,而不是按文件类型或技术栈划分。
什么叫按生命周期划分?就是思考一份数据、一段代码、一个配置文件从“产生”到“下线”会经历哪些阶段,然后让目录结构跟着这个流程走。常见的生命周期阶段包括:
- 数据获取与处理:原始数据进来,清洗加工,变成模型可用的格式。
- 模型开发与训练:包括训练脚本、模型输出、评测脚本。
- 配置与参数管理:把 Prompt、模型参数、Agent 配置从代码中剥离。
- 推理与部署:模型上线,提供服务,记录日志。
- 测试与评估:验证效果,回归测试,质量保障。
基于这个思路,一个典型的生成式 AI 项目目录结构应该长这样:
code复制project_root/
├── configs/ # 所有配置文件
├── data/ # 数据资产
│ ├── raw/ # 原始数据
│ ├── processed/ # 加工后数据
│ └── synthetic/ # 合成数据
├── src/ # 源代码
│ ├── data/ # 数据处理代码
│ ├── models/ # 模型定义与加载
│ ├── inference/ # 推理逻辑
│ ├── agents/ # Agent 相关工作流
│ └── utils/ # 工具函数
├── prompts/ # Prompt 模板
├── tests/ # 测试代码
├── scripts/ # 脚本(训练、评测、部署)
├── outputs/ # 输出产物
│ ├── models/ # 模型权重
│ ├── results/ # 评测结果
│ └── logs/ # 运行日志
├── docs/ # 文档
├── requirements.txt # 依赖
└── README.md
这个结构看起来简单,但是每个目录的取舍背后都有讲究。数据、Prompt、模型权重和代码分开,是为了避免“代码改动”和“内容改动”互相污染;配置和代码分离,是为了不同环境(开发、测试、生产)能复用同一套代码逻辑;输出和源码分离,是为了保证源码目录的整洁,也方便做产物管理。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 核心细节解析与实操要点
2.1 数据层设计:raw、processed、synthetic 的三段式划分
生成式 AI 项目里,数据往往是最容易被忽视工程化的地方。很多团队的数据管理方式是“文件夹里堆文件”,文件名后面加 _final、_final_v2、_final_真的不改了 这种后缀。这种方式在小规模实验时勉强能用,项目一大了就完全失控。
标准做法是把数据分成三个子目录:raw/、processed/、synthetic/。
data/raw/放原始数据,也就是从外部采集、标注、导出的原始产物。这个目录里的数据应该是只读的,任何处理都不能改动原始文件。它的存在是为了保证“数据可回溯”——如果处理逻辑出了问题,可以随时回到原始数据重新跑一遍。data/processed/放经过清洗、格式化之后的数据。这个目录是模型训练和评测真正消费的数据。处理流程要保证幂等性,也就是同一份 raw 数据跑同一套处理脚本,产出的 processed 数据应该完全一致。data/synthetic/放合成数据。生成式 AI 项目里合成数据的应用非常普遍,比如用大模型生成训练数据、构造对抗样本、生成评测集等。单独划一个目录,是为了区分数据来源,避免合成数据和真实数据混淆。
这三个目录的划分,本质上是在数据层面建立“源、流、汇”的关系。raw 是源头,processed 是加工后的产物,synthetic 是额外引入的增强数据。在代码里引用数据时,也需要约定:业务代码只能读取 processed/ 和 synthetic/,不能直接读取 raw/。这个约定能强制团队把数据清洗逻辑沉淀下来,而不是每次都在 notebook 里手工处理数据然后到处复制粘贴。
2.2 配置管理:YAML 统一管理 Prompt、模型参数与 Agent 配置
生成式 AI 项目的配置管理,比传统后端项目要复杂得多。因为除了常规的环境变量、数据库连接串,还牵扯到 Prompt 模板、模型参数(temperature、top_p、max_tokens)、Agent 的工具列表和系统设定等。这些配置如果散落在代码里,每次调整都要重新部署,而且极易出错。
我的做法是:所有配置统一用 YAML 文件管理,放在 configs/ 目录下。YAML 比 JSON 可读性好,比 INI 支持嵌套结构,是这类配置的最佳载体。一个典型的大模型配置长这样:
yaml复制# configs/llm_config.yaml
llm:
provider: "openai"
model_name: "gpt-4o"
temperature: 0.7
top_p: 0.9
max_tokens: 2048
timeout: 60
retry_times: 3
agent:
name: "customer_service_assistant"
system_prompt_key: "customer_service_v3"
max_steps: 10
tools:
- "order_query"
- "refund_apply"
- "logistics_track"
prompt:
customer_service_v3:
template: "prompts/customer_service.txt"
variables:
user_name: "用户"
business_line: "电商"
有了统一的配置层,代码里就不应该再出现硬编码的 Prompt 或模型参数。比如写 Agent 的初始化逻辑时,从配置加载:
python复制import yaml
from pathlib import Path
def load_config(config_name: str) -> dict:
config_path = Path("configs") / f"{config_name}.yaml"
with open(config_path, "r", encoding="utf-8") as f:
return yaml.safe_load(f)
# 使用
config = load_config("llm_config")
llm_params = config["llm"]
prompt_key = config["agent"]["system_prompt_key"]
这里有个很关键的经验:Prompt 内容不要直接写在 YAML 里,而是放模板文件,YAML 只保存模板路径。原因是 Prompt 经常要调,内容通常比较长,塞进 YAML 会导致配置文件臃肿,也不方便做版本对比。把 Prompt 拆到 prompts/ 目录,每个模板独立成一个文本文件,用文件名代替 Key 来引用,改 Prompt 的效率会高很多。
2.3 模型与推理层:weights、adapter、tokenizer 分层管理
生成式 AI 项目的模型管理,最容易踩的坑是“模型文件和组织结构脱离”。模型要么放在随机目录,要么放在代码目录里,导致模型更新后代码跟着乱,或者两个人的模型版本不一致。
我推荐的做法是:模型相关文件统一放在 outputs/models/ 下,并按“底座模型 + 适配器”的维度组织:
code复制outputs/models/
├── base_models/ # 基础大模型权重
│ ├── qwen7b_chat/
│ └── llama3_8b_instruct/
├── finetuned/ # 微调后的模型
│ └── customer_service_v3/
└── adapters/ # LoRA 等适配器权重
└── sentiment_lora/
base_models/ 存放从 HuggingFace 等渠道下载的基础模型,这部分一般只读;finetuned/ 存放全量微调后的模型;adapters/ 存放 LoRA 等轻量级适配器。这样设计的直接好处是:一个适配器可以搭配多个基础模型做对比实验,而不用复制整套模型权重。
推理代码应该通过统一的接口加载模型,而不是在多个文件里各自 from_pretrained。一个简单的封装:
python复制# src/models/loader.py
import os
from transformers import AutoModelForCausalLM, AutoTokenizer
MODEL_BASE_PATH = os.getenv("MODEL_BASE_PATH", "outputs/models")
def load_model_and_tokenizer(model_name: str, use_adapter: str = None):
model_path = os.path.join(MODEL_BASE_PATH, "finetuned", model_name)
model = AutoModelForCausalLM.from_pretrained(model_path)
tokenizer = AutoTokenizer.from_pretrained(model_path)
if use_adapter:
adapter_path = os.path.join(MODEL_BASE_PATH, "adapters", use_adapter)
model.load_adapter(adapter_path)
return model, tokenizer
注意这个 MODEL_BASE_PATH 要支持环境变量覆盖。因为本地开发和服务器部署的模型路径往往不一样,硬编码绝对路径会导致换环境就跑不了。
3. 实操过程与核心环节实现
3.1 从零搭建标准目录:一份可直接抄作业的脚手架
理论说再多都不如动手实践。下面我演示一个实际项目中我是怎么一步步搭建目录的,你可以直接照着操作。
第一步,创建顶层目录结构。在项目根目录执行:
bash复制mkdir -p {configs,data/{raw,processed,synthetic},src/{data,models,inference,agents,utils},prompts,tests,scripts,outputs/{models,results,logs},docs}
第二步,初始化代码仓库。先写好 .gitignore,明确哪些目录不进版本控制:
gitignore复制# .gitignore
__pycache__/
*.pyc
.ipynb_checkpoints/
.env
data/raw/*
!data/raw/.gitkeep
data/processed/*
!data/processed/.gitkeep
outputs/models/*
!outputs/models/.gitkeep
outputs/results/*
!outputs/results/.gitkeep
outputs/logs/*
!outputs/logs/.gitkeep
这里有个设计细节:raw/、processed/、models/ 的目录都保留 .gitkeep 文件让空目录能进仓库,但里面的实际数据不进版本控制。数据资产应该走独立的存储方案(比如 oss、NAS),而不是塞进 Git 仓库。Git 仓库只保留代码、配置、Prompt 这些文本资产,这个原则能避免仓库无限膨胀。
第三步,搭建 src 包的初始结构。为每个子包创建 __init__.py:
bash复制touch src/data/__init__.py src/models/__init__.py src/inference/__init__.py
touch src/agents/__init__.py src/utils/__init__.py
第四步,创建全局配置文件入口。src/utils/config.py 提供统一的配置加载能力:
python复制# src/utils/config.py
from pathlib import Path
import yaml
PROJECT_ROOT = Path(__file__).resolve().parents[2]
def get_project_root() -> Path:
"""获取项目根目录的绝对路径"""
return PROJECT_ROOT
def load_yaml(relative_path: str) -> dict:
"""加载 configs 目录下的 YAML 配置"""
full_path = PROJECT_ROOT / "configs" / relative_path
if not full_path.exists():
raise FileNotFoundError(f"Config file not found: {full_path}")
with open(full_path, "r", encoding="utf-8") as f:
return yaml.safe_load(f)
这里的核心思路是:所有路径都从项目根目录出发,禁止使用相对路径依赖“当前工作目录”。因为生成式 AI 项目的脚本经常在命令行、定时任务、Docker 容器里来回切换,依赖 os.getcwd() 会让同样的代码在不同环境下跑出不同的数据文件路径。通过 Path(__file__) 反推项目根目录,无论从哪里启动程序都能定位到正确的文件。
3.2 从数据导入到 Prompt 调用:一条完整的执行链路
搭好目录之后,我用一个“知识库问答”的简化例子,演示数据怎么从原始文件走到最终生成的回答。
假设业务是要做一个基于产品文档的问答机器人。原始文档放在 data/raw/product_docs/,处理流程是把文档切分成适合向量检索的块。数据处理代码放在 src/data/processor.py:
python复制# src/data/processor.py
from pathlib import Path
from src.utils.config import get_project_root
def split_documents(input_dir: str, output_dir: str, chunk_size: int = 500):
"""将原始文档切分为文本块,输出到 processed 目录"""
root = get_project_root()
raw_dir = root / "data" / "raw" / input_dir
proc_dir = root / "data" / "processed" / output_dir
proc_dir.mkdir(parents=True, exist_ok=True)
for doc_path in raw_dir.glob("*.txt"):
content = doc_path.read_text(encoding="utf-8")
chunks = [content[i:i+chunk_size] for i in range(0, len(content), chunk_size)]
# 输出为 jsonl,每一行是一个分块
output_file = proc_dir / f"{doc_path.stem}.jsonl"
with open(output_file, "w", encoding="utf-8") as f:
for idx, chunk in enumerate(chunks):
f.write(json.dumps({"id": f"{doc_path.stem}_{idx}", "text": chunk}, ensure_ascii=False) + "\n")
处理后数据落到 data/processed/product_docs/,模型就可以基于这些向量化后的分块做检索增强生成。注意这里的输入输出路径都用相对项目根目录的方式,调用方不需要关心机器上的绝对路径。
接着在 src/inference/rag_pipeline.py 里实现完整的 RAG 链路:
python复制# src/inference/rag_pipeline.py
import json
from pathlib import Path
from src.utils.config import get_project_root, load_yaml
from src.models.loader import get_embedding_model, get_chat_model
class RAGPipeline:
def __init__(self, config_name: str = "rag_config"):
self.config = load_yaml(f"{config_name}.yaml")
self.embedding_model = get_embedding_model(self.config["embedding"])
self.chat_model = get_chat_model(self.config["llm"])
self.doc_index = self._load_index()
self.prompt_template = self._load_prompt_template()
def _load_index(self):
index_path = get_project_root() / "data" / "processed" / self.config["index_path"]
chunks = []
for jsonl_file in index_path.glob("*.jsonl"):
with open(jsonl_file, "r", encoding="utf-8") as f:
for line in f:
chunks.append(json.loads(line))
return chunks
def _load_prompt_template(self):
template_path = get_project_root() / "prompts" / self.config["prompt_template_path"]
return template_path.read_text(encoding="utf-8")
def query(self, user_question: str) -> str:
similar_chunks = self._retrieve(user_question)
context = "\n".join([item["text"] for item in similar_chunks])
prompt = self.prompt_template.format(context=context, question=user_question)
return self.chat_model.generate(prompt)
这个链路不是重点,重点是它的依赖关系非常清晰:推理代码只依赖配置、处理后的数据和 Prompt 模板,不直接触碰原始文档,也不管模型权重放在哪台机器的哪个路径。这就是标准化目录结构带来的解耦效果。
3.3 一套可复用的 Prompt 模板组织方案
Prompt 是生成式 AI 项目里迭代最频繁的资产。我自己管理 Prompt 的方式是:一个场景一个目录,目录里包含主模板和可复用的片段。
code复制prompts/
├── customer_service/
│ ├── main.txt # 主 Prompt
│ ├── tone_rules.txt # 语气规则片段
│ └── knowledge_tips.txt # 知识使用提示
├── summarization/
│ └── main.txt
└── rag/
└── main.txt
主模板里通过占位符引用子片段,比如:
code复制你是一个专业的客服助手,回答问题时请遵循以下规则:
{tone_rules}
当用户提到产品功能问题时,参考以下知识使用建议:
{knowledge_tips}
以下是用户的问题:
{question}
请用简洁、友好的语气回答:
在代码中加载模板时,可以写一个 load_prompt 工具函数,自动处理片段的拼接和变量替换:
python复制# src/utils/prompt_loader.py
from pathlib import Path
from src.utils.config import get_project_root
def load_prompt_template(scene: str, template_name: str = "main.txt") -> str:
prompt_dir = get_project_root() / "prompts" / scene
template_path = prompt_dir / template_name
return template_path.read_text(encoding="utf-8")
def render_prompt(template: str, variables: dict) -> str:
"""用变量渲染 Prompt,并自动加载 {xxx} 对应的片段文件"""
import re
def replace_match(match):
key = match.group(1)
if key in variables:
return str(variables[key])
# 尝试在 prompts 同目录下寻找同名片段文件
return load_prompt_template("_shared", f"{key}.txt")
return re.sub(r"\{(\w+)\}", replace_match, template)
这里要特别提醒一个常见的错误:不要把用户输入直接拼接进 Prompt,一定要做变量转义或格式校验。理由很实际,Prompt 注入是生成式 AI 应用最常见的安全风险。如果直接把用户的字符串拼进模板,用户可以构造恶意输入让模型忽略原有设定,这在客服机器人、内容生成工具里都可能导致严重后果。比较稳妥的方式是,对用户输入做长度限制,再进行转义,把输入作为“被引用内容”而不是“指令内容”来处理。
4. 常见问题与排查技巧实录
4.1 目录结构迁移中的典型坑位
标准化目录结构不是一步到位的,老项目迁移的时候遇到的问题最多。我把实际踩过的坑列成一张速查表:
| 问题 | 现象 | 原因 | 解决方案 |
|---|---|---|---|
| 路径硬编码 | 换台机器跑脚本报 FileNotFoundError | 代码里写了绝对路径 | 统一改用 get_project_root() 定位 |
| 相对路径依赖 | 在项目根目录能跑,在 scripts 目录下跑就报错 | 使用了 os.getcwd() |
禁止依赖当前工作目录,全部基于 __file__ 反推 |
| 数据重复 | 同一份数据在 3 个目录各有一份副本 | 没有约定数据唯一来源 | 明确 raw 是唯一源头,其他目录只放派生数据 |
| Prompt 改动不可追溯 | 某个 Prompt 改完不知道影响哪些模型 | Prompt 硬编码在代码里 | Prompt 抽离到 prompts/,通过配置引用 |
| 模型版本混乱 | 代码回滚后模型和代码不匹配 | 模型版本没有与代码版本关联 | 模型目录按版本命名,配置中锁定版本号 |
这里重点说下“模型版本和代码版本不匹配”的问题。生成式 AI 项目很容易出现“代码是旧的,模型是新的”这种情况。原因在于代码有版本控制,但模型权重体积大,通常不存放在代码仓库里,版本全靠自觉。我的建议是:在配置文件中显式声明模型版本号,并在推理初始化时打印版本标识,让版本信息出现在日志里。这样即使出了问题,也能快速定位是哪一版代码配了哪一版模型。
4.2 关于内容安全与审核机制的工程化落地,必须单独聊聊
生成式 AI 项目里有一个非常容易被低估的方向:内容安全与审核机制。不管你的应用是客服机器人、内容创作工具还是知识问答,上生产之前都必须考虑输出内容的合规性与安全性。这不是可选项,而是工程化落地的基本要求。
所谓“无限制无审核”的输出,在真实的生产环境里是灾难,不是优势。一个不做内容审核的生成式应用,可能在第一周就会遇到用户诱导模型输出不当内容、生成内容包含有害信息、或者被恶意用户用来批量制造垃圾信息等问题,轻则产品下架,重则有更严重的后果。所以工程化的目录结构里,一定要为安全能力预留位置。
我推荐的做法是:在 src/utils/ 下增加安全检测模块,和 Prompt 模板、模型配置一样,作为生成链路的标准环节。
python复制# src/utils/safety_checker.py
import re
# 定义输入输出的关键词和规则
BLOCKED_PATTERNS = [
r"\b(?:hack|crack|exploit)\b",
# 更多规则...
]
class SafetyChecker:
"""输入输出内容安全检测"""
def check_input(self, user_message: str) -> bool:
"""校验用户输入,返回 True 表示通过"""
if len(user_message) > 2000:
return False
if any(re.search(pattern, user_message, re.I) for pattern in BLOCKED_PATTERNS):
return False
return True
def check_output(self, model_output: str) -> bool:
"""校验模型输出,返回 True 表示通过"""
if len(model_output) > 4000:
return False
if any(re.search(pattern, model_output, re.I) for pattern in BLOCKED_PATTERNS):
return False
return True
然后在推理链路里加上安全检测:
python复制def query_with_safety_check(self, user_question: str) -> str:
if not self.safety_checker.check_input(user_question):
return "抱歉,我无法处理这个请求。"
raw_answer = self.chat_model.generate(prompt)
if not self.safety_checker.check_output(raw_answer):
return "我暂时无法回答这个问题,请换个说法试试。"
return raw_answer
关键词检测只是最基础的一层,更完整的做法还包括:输入输出的哈希缓存、敏感行为的频控、人工审核的抽检队列。这些能力在目录结构里都应该有对应的存放位置,src/utils/safety_checker.py 放核心判断逻辑,configs/safety_config.yaml 放规则配置,outputs/logs/safety_logs/ 放审核日志。这也再次说明了目录结构的重要性——你只有给某个能力预留了位置,它才会被认真建设。
4.3 团队协作中的目录约定执行经验
目录结构定好了,真正难的是让团队所有人都遵守。我见过不少项目,结构设计得很漂亮,但一个月之后就又乱了。原因不是目录设计有问题,而是没有配套的“执行机制”。
我实际执行下来有效的手段有三个:
第一,在 README 里面明确每个目录的“责任者”。不只是一段描述,而是表格形式,标注每个目录由谁负责、维护频率、哪些内容不能放进去。新人入职先看这个表,能少踩很多坑。
第二,把目录检查做成 CI 的一部分。GitHub Actions 或 GitLab CI 里加一个检查脚本,扫描提交的代码是否包含了不应该出现在源码目录的文件。比如 models/ 或 data/ 下有二进制文件被误提交,就立刻报警。这比靠人自觉可靠得多。
第三,约定“目录变更必须走讨论”。如果谁想新增一个顶层目录,必须说明理由和用途,不能自己想加就加。顶层目录的扩张要克制,因为每多一个目录,团队的理解成本就高一分。
这个经验可能听起来很“软”,但在实际协作中往往决定了工程化能坚持多久。一套好的目录结构,不仅是技术设计,更是一种团队契约。
4.4 扩展方向:从单体服务走向 LLMOps
如果你已经按照标准化目录结构把项目组织好了,接下来一个很自然的方向就是往 LLMOps 演进。我的建议是,在现有目录基础上逐步增加这几个能力:
- 实验追踪:在
outputs/results/里为每次实验建立一个子目录,命名格式是YYYYMMDD_描述,存放评测报告和样本结果,方便对比。 - Prompt 版本管理:Prompt 文件建议纳入 Git 管理,每次修改通过 Commit 记录留下痕迹。条件允许的话,可以给 Prompt 打 Tag,部署时锁定 Tag。
- 评测集沉淀:建立固定的评测集放在
data/processed/eval_sets/,每次模型或 Prompt 变更后都要跑同一套评测集,把结果提交到outputs/results/。
这些能力不是一步到位的,可以随着项目的发展逐步补齐。关键点是,标准化的目录结构给了它们一个“落位”的基础,你不需要某天突然推倒重来,而可以在现有框架里平滑扩展。
5. 结尾
做生成式 AI 项目这几年,我最大的体会是:模型效果决定了一个项目的上限,而工程化水平决定了下限。再强的模型,如果没有一套清晰的组织方式来承载,最终都会被混乱的协作和脆弱的部署拖垮。
标准化目录结构看似不起眼,却是工程化范式里性价比最高的一笔投入。它不需要你引入复杂的框架,不需要重写业务代码,只需要你在项目开始的时候多花半小时定好骨架,并且在后续的迭代里守住边界。按照我的经验,这半小时的投入,会在项目进入第二个迭代周期之后带来数倍的回流。
最后再分享一个小技巧:如果你是在一个已有项目里推行目录标准化,不要试图一步到位地重构,那样风险极高。比较稳妥的做法是,每改一个模块就顺手把它迁移到新目录,同时更新对应的调用方。让迁移和日常迭代交织在一起,既不会耽误业务进度,又能稳步地把项目拽回正轨。标准化的价值是长期主义的,坚持执行,时间会给你答案。
