☰
从零搭建个人AI助手:项目初始化与Model I/O实战
2026/10/12 12:42:46 网站建设 项目流程

Agent实践系列:个人AI助手——阶段1项目初始化与Model I/O落地

这期是这个系列的第七篇。前面聊了好几轮Agent的思路和框架,后台也有不少人催更,问什么时候能跑一个完整项目出来。这次我直接给自己定了目标——做一个个人AI助手,不是玩具,是真正能持续迭代、以后要接记忆、接工具、接多轮对话的那种产品级Demo。

既然是开篇,这一篇先把两件最核心的事讲透:项目怎么初始化、基础的Model I/O怎么设计。如果你也想照着搭一个,或者你正卡在"框架文档看了一堆,手底下一个文件都还没建"的阶段,那这篇文章就是给你写的。

1. 整体设计思路与选型判断

1.1 这个阶段要解决的问题

个人AI助手这个项目,往大了说可以无限做加法。但我不打算一上来就把架构铺得天花板那么高。阶段1只聚焦三件事:

  • 项目骨架:目录结构、虚拟环境、配置文件、依赖管理,这些决定了后续能不能干净地加功能。
  • 模型接入:让代码能稳定地调起大模型,并且对不同厂商的接口有一定的适配能力。
  • 输入输出规范化:不光是打印字符串,而是把"用户输入→提示词→模型返回→结构化结果"这条链路做成可复用的组件。

说白了,就是把地基打牢。很多项目死在半路上的原因不是功能不够多,而是前期就没有想清楚基础的调用链怎么组织,到后面代码全是补丁摞补丁。

1.2 为什么选择当前这套技术组合

在框架选型上,我几乎没犹豫就选了LangChain。原因很简单:后续的个人AI助手要做到对话管理、工具调用、记忆持久化,这些LangChain都有成体系的生态。虽然2023到2024年之间社区里关于LangChain的争议一直没停过,但对比下来,它的组件抽象度对于个人项目来说依然是最省力的。

模型这边我选择的是走OpenAI兼容接口协议。这不代表我只用某一家模型,而是因为现在绝大多数模型服务都提供了兼容接口——不管是官方的还是第三方的网关服务,一套代码就能切换模型,对前期项目起步阶段来说非常友好。

# 记住这个原则:面向协议编程,而不是面向某家厂商编程

Python版本我用了3.11,不要用3.12,某些依赖的编译在3.12上还是有小坑。这一点在后面"常见问题"部分会展开讲。

1.3 分阶段规划的视角

这个项目我计划分四个阶段走:

  1. 阶段1(本篇):项目初始化 + 基础Model I/O
  2. 阶段2:对话记忆 + 多轮上下文管理
  3. 阶段3:工具调用 + Agent行为编排
  4. 阶段4:交互界面 + 部署

本篇先做第一阶段。但我设计的时候会把后面几阶段的扩展点提前预留好——比如配置文件的参数命名、工具注册表的目录位置,这些现在多花五分钟,以后省两小时。

2. 项目初始化实操全记录

2.1 目录结构设计

我见过很多人初始化项目的时候直接pip install langchain然后创建一个main.py就开写。这种开始方式写Demo没问题,但要是打算持续迭代,后面必然有一步重构逃不掉。

我这次的目录结构是这样的:

personal-ai-assistant/ ├── .env.example # 环境变量模板 ├── .env # 本地环境变量(不入库) ├── .gitignore ├── pyproject.toml # 项目依赖 ├── README.md ├── app/ │ ├── __init__.py │ ├── config.py # 配置管理 │ ├── main.py # 入口 │ ├── models/ # 模型I/O层 │ │ ├── __init__.py │ │ ├── llm.py # 模型封装 │ │ ├── prompts.py # 提示词模板 │ │ └── parsers.py # 输出解析器 │ └── utils/ │ ├── __init__.py │ └── logger.py # 日志工具 └── tests/ ├── __init__.py └── test_model_io.py

说一下为什么这样分:

  • models/这个目录,很多人会直接叫core/或者services/,但我特意叫models是因为这一层放的就是和模型直接交互的东西。以后阶段2加memory/,阶段3加tools/,跟models/是平级的兄弟模块。
  • app/config.py单独拿出来,而不是直接在main.py里读环境变量。配置文件被多个模块引用,独立成文件是基本操作。
  • tests/从第一天就建好。阶段1不写测试的话,你以后大概率永远都不会补。

2.2 依赖管理与环境准备

用pyproject.toml而不是requirements.txt。推荐用uv来管理,虽然刚开始上手有一点学习成本,但体验比pip丝滑太多。

[project] name = "personal-ai-assistant" version = "0.1.0" description = "Personal AI Assistant - Phase 1" requires-python = ">=3.11" dependencies = [ "langchain>=0.2.0", "langchain-openai>=0.1.0", "pydantic>=2.6.0", "python-dotenv>=1.0.0", "rich>=13.7.0", ]

其中rich是我个人的私心推荐,用来做命令行输出格式化,比print好看太多,而且零学习成本。

如果用的是uv:

uv init personal-ai-assistant cd personal-ai-assistant uv add langchain langchain-openai pydantic python-dotenv rich

实测下来这套组合没有什么依赖冲突。唯一要注意的是版本号,我这些版本是验证过的,如果你要用pip安装,请务必锁定大版本,不要直接装最新版——这个行业里依赖更新往往伴随着破坏性变更。

2.3 配置文件管理

.env.example长这样:

# Model Provider LLM_PROVIDER=openai_compatible LLM_MODEL_NAME=gpt-4o-mini LLM_API_KEY=sk-your-key-here LLM_BASE_URL=https://api.openai.com/v1 LLM_TEMPERATURE=0.7 LLM_MAX_TOKENS=4096 LLM_TIMEOUT=60 # App Settings APP_LOG_LEVEL=INFO APP_LOG_FILE=logs/app.log

这里有一个容易被新手忽略的设计细节:我保留了LLM_BASE_URL这个配置项,而且没有写死。因为后面不管是切换模型服务商,还是本地部署模型服务,改配置就能切,不用改代码。这就是前面说的"面向协议编程"的落地方式。

config.py用pydantic-settings来做配置校验:

from pydantic_settings import BaseSettings, SettingsConfigDict class Settings(BaseSettings): model_config = SettingsConfigDict( env_file=".env", env_file_encoding="utf-8", extra="ignore", ) # Model llm_provider: str = "openai_compatible" llm_model_name: str = "gpt-4o-mini" llm_api_key: str = "" llm_base_url: str = "https://api.openai.com/v1" llm_temperature: float = 0.7 llm_max_tokens: int = 4096 llm_timeout: int = 60 # App app_log_level: str = "INFO" app_log_file: str = "logs/app.log" settings = Settings()

为什么用pydantic-settings而不是直接os.getenv?因为它会自动从.env读值并做类型转换。llm_temperature如果用os.getenv拿到的是字符串"0.7",你还得手动float()。这种细节积少成多,框架帮你做掉才是省力气的做法。

注意一个坑:extra="ignore"这个参数必须写,否则.env里多一个未定义的键就会直接抛异常。

2.4 Git初始化

项目文件建好之后,记得第一时间git init并提交。不是走形式,而是当你后面写代码写乱了想回滚的时候,你会感谢当初这个五分钟不到的操作。

gitignore至少包含这些:

.env logs/ __pycache__/ *.pyc .venv/ .pytest_cache/

logs/目录直接一起忽略了,运行时自动创建。

3. 基础Model I/O核心实现

Model I/O 是LangChain框架三大核心组件之一(另外两个是Memory和Agent,后面阶段会用到)。它包含三个子模块:Prompt、Model、Output Parser。

我直接按这三个维度来讲代码实现。每个环节讲清楚代码背后解决了什么问题,而不是只贴代码。

3.1 模型封装层(llm.py)

模型封装是整个阶段的中心点。不管以后是换了模型、加了重试还是接入了多模型路由,都是从这一层改起。

from langchain_openai import ChatOpenAI from langchain_core.language_models import BaseChatModel from ..config import settings def create_llm() -> BaseChatModel: """创建统一的大模型实例""" return ChatOpenAI( model=settings.llm_model_name, api_key=settings.llm_api_key, base_url=settings.llm_base_url, temperature=settings.llm_temperature, max_tokens=settings.llm_max_tokens, timeout=settings.llm_timeout, )

这里用ChatOpenAI而不是OpenAI,关键在于ChatOpenAI返回的是BaseChatModel类型,天然支持后续要做的一系列链式调用。它会自动把模型响应包成AIMessage对象,而不是光秃秃的字符串——这个抽象在后面阶段会显得特别有用。

注意:ChatOpenAI的timeout参数单位是秒。我习惯设60秒,碰到长输出的时候不至于把一个请求卡死半小时。

如果一个项目里需要同时接多个模型怎么办?比如助手主模型用A家的,摘要模型用B家的。我建议在create_llm()上再加一层工厂函数,用LLM_PROVIDER来决定实例化哪个类。

def create_llm_by_provider(provider: str = None) -> BaseChatModel: provider = provider or settings.llm_provider if provider == "openai_compatible": return create_llm() elif provider == "anthropic": from langchain_anthropic import ChatAnthropic return ChatAnthropic( model=settings.llm_model_name, api_key=settings.llm_api_key, ) else: raise ValueError(f"未知的模型提供方: {provider}")

现阶段不要过度设计,但这一层工厂模式建议先留着,成本就十分钟,后面换模型不用去每个调用点改代码。

3.2 Prompt模板(prompts.py)

个人AI助手的第一版提示词,目标不是让它像一个专家,而是让它成为一个可控的对话者。我设计了两套Prompt:一套是通用助手的系统提示词,一套是用于特定任务(比如提炼总结)的模板。

from langchain_core.prompts import ChatPromptTemplate from langchain_core.prompts import MessagesPlaceholder # 通用助手系统提示词 SYSTEM_PROMPT = """你是一个高效、友好的个人AI助手。 你的定位是帮助用户解决问题,而不是替代用户思考。回答时请注意: 1. 优先给出可以直接执行的方案,不要绕弯子 2. 如果信息不充分,主动询问,不要猜 3. 回答用中文,专业名词可以保留英文 4. 代码、命令等示例需要用代码块展示 5. 对于用户提出的要求,直接输出结果,不要添加关于"作为AI助手"的免责声明 """ # 基础对话模板 general_prompt = ChatPromptTemplate.from_messages( [ ("system", SYSTEM_PROMPT), MessagesPlaceholder(variable_name="history"), ("human", "{input}"), ] ) # 任务型Prompt模板 task_prompt = ChatPromptTemplate.from_messages( [ ("system", SYSTEEM_PROMPT), ( "system", "当前任务:{task_description}。请严格按照要求执行,不要偏离任务范围。", ), ("human", "{input}"), ] )

这里有几个关键设计决策:

第一,用MessagesPlaceholder而不是直接拼接历史消息字符串。这个位置是给阶段2的记忆模块预留的——到时候把历史消息列表填进去就行,模板不用改。

第二,任务型Prompt和通用Prompt分开,但不拆分文件。都在prompts.py里维护。因为目前规模还不大,拆太碎反而增加翻文件的成本。

第三,系统提示词里明确写了"不要添加关于'作为AI助手'的免责声明"——实测下来很多模型默认会带这一句,又啰嗦又影响体验。通过提示词约束能有效减少这种废话。

有读者可能会问:直接用ChatPromptTemplate会不会限制我写更复杂的Prompt?答案是不会。ChatPromptTemplate支持任意多段消息组合,system、human、ai、tool 消息都可以混排,灵活性足够覆盖绝大多数场景。如果你后面要用Agent,LangChain官方模板里同样推荐用它来定义Agent的推理模板。

3.3 输出解析器(parsers.py)

这一节是整个阶段1里最有"含金量"的部分。写LLM应用,最讨厌的就是模型返回不稳定的格式。你今天让模型返回JSON,它给你格式完整;明天同一个问题,它可能就在JSON外面包了一层```块。

所以我直接用PydanticOutputParser来强制模型按结构输出。思路很简单:定义好响应数据的结构(用Pydantic模型),解析器会把这个结构描述自动注入到Prompt里,然后在模型返回结果时做校验和解析。

from typing import List, Optional from pydantic import BaseModel, Field from langchain_core.output_parsers import PydanticOutputParser # 定义结构化输出格式 class AIAssistantResponse(BaseModel): """AI助手回复的结构化格式""" reply: str = Field(description="给用户的最终回复内容") related_topics: List[str] = Field( default_factory=list, description="与本次对话相关的主题词,最多5个", ) needs_clarification: bool = Field( description="信息是否充分,是否需要向用户追问", ) suggested_questions: Optional[List[str]] = Field( default=None, description="如果需要追问,给出2-3个参考问题;否则为null", ) # 创建解析器 assistant_parser = PydanticOutputParser(pydantic_object=AIAssistantResponse) # 获取格式指令 FORMAT_INSTRUCTIONS = assistant_parser.get_format_instructions()

get_format_instructions()返回的是一段很长的JSON Schema指令,它会告诉模型"你只能返回符合这个结构的JSON"。有了这个,模型的输出格式稳定性会从20%左右直接拉到95%以上。

但只把format_instructions塞进Prompt还不够,因为模型偶尔还是会犯错。实际使用时我包裹一个安全解析函数:

from langchain_core.output_parsers import OutputParserException import json import re def safe_parse_ai_response(text: str) -> AIAssistantResponse: """安全解析模型输出,带异常兜底""" try: return assistant_parser.parse(text) except OutputParserException: # 尝试提取文本中的JSON块(兼容被code block包裹的情况) json_match = re.search(r"```json\s*\n(.*?)\n```", text, re.DOTALL) if json_match: try: data = json.loads(json_match.group(1)) return AIAssistantResponse(**data) except Exception: pass # 最后的兜底:强制装填,只保留原始的文本回复 return AIAssistantResponse( reply=text.strip(), related_topics=[], needs_clarification=False, suggested_questions=None, )

多说一句,这里related_topics、needs_clarification这些字段不是拍脑袋定的。它们是给阶段4做界面交互时预留的:界面上可以展示相关主题推荐、追问引导按钮。如果你确定自己的场景根本用不到这些,删掉一些字段完全没问题,但至少加一个结构化字段,哪怕它只是描述性的元信息——它会逼迫模型的输出逻辑变得规整。

第三点,兜底策略里我是"宁可丢弃结构化信息,也不要把整段回复扔掉"。用户永远应该拿到模型的回复内容,格式是次要的。这在真实产品里是铁律。

3.4 串联跑通(main.py)

有了上面三个组件,入口文件就非常简单了。串联逻辑是:Prompt Template → Model → Output Parser。

from rich.console import Console from rich.panel import Panel from .models.llm import create_llm from .models.prompts import general_prompt, SYSTEM_PROMPT from .models.parsers import safe_parse_ai_response, FORMAT_INSTRUCTIONS console = Console() llm = create_llm() def chat_once(user_input: str) -> None: # 1. 构建带格式约束的Prompt prompt_value = general_prompt.invoke( { "history": [], "input": user_input, # 在某一个message里注入format指令 } ) # 2. 模型调用 # 这里的messages最后一条加上格式要求 messages = prompt_value.to_messages() messages[-1].content += f"\n\n请确保输出采用下面的格式:\n{FORMAT_INSTRUCTIONS}" response = llm.invoke(messages) # 3. 解析输出 parsed = safe_parse_ai_response(response.content) # 4. 展示 console.print(Panel(parsed.reply, title="AI助手", border_style="blue")) if parsed.needs_clarification and parsed.suggested_questions: console.print("[yellow]补充询问:[/yellow]") for q in parsed.suggested_questions: console.print(f" - {q}") console.print(f"[dim]相关主题:{', '.join(parsed.related_topics)}[/dim]")

看到上面的写法,你可能会有一个疑问:为什么把FORMAT_INSTRUCTIONS注入到最后的human消息,而不是直接放到system提示词里?

我踩过的坑是:某些模型对system消息中的JSON Schema格式要求遵守得并不好,但把它作为user消息末尾的显式约束,遵守率会显著上升。这不算正规的做法,但实测有效。LLM应用就是经验学科,能在实际中跑通,就是好方案。

main函数加一个简单的交互循环:

def main(): console.print("[bold]个人AI助手 v0.1 - 阶段1 Model I/O 已就绪[/bold]") console.print("[dim]输入 'exit' 退出,'clear' 清空上下文[/dim]\n") while True: try: user_input = input("你 > ").strip() except (KeyboardInterrupt, EOFError): console.print("\n[dim]再见[/dim]") break if user_input.lower() in ("exit", "quit"): console.print("[dim]再见[/dim]") break if user_input.lower() == "clear": console.print("[dim]上下文已重置(阶段2后再支持真正的记忆)[/dim]") continue if not user_input: continue chat_once(user_input) if __name__ == "__main__": main()

跑起来效果就是这样,一个交互式的命令行助手已经能说话了。虽然还没有记忆功能,但你可以感受到"模型I/O链路"已经通了。

4. 常见问题与排查技巧实录

这个部分,我把最初踩过的坑按"现象→原因→解决"的方式整理成速查表,希望能帮你省掉几个晚上的排查时间。

4.1 API Key相关的坑

现象原因解决
代码报401.env文件没被加载确保.env在项目根目录,且config.py中env_file=".env"路径正确
报403Key 没有对应模型的权限检查模型服务平台上Key的权限范围
一直转圈后超时base_url是https://开头但缺少/v1路径确认拼接后的URL能直接访问到chat completions接口
本地设置了OPENAI_API_KEY环境变量导致代码忽略.env环境变量优先级比.env高检查当前shell环境变量,用unset OPENAI_API_KEY清掉

第一个坑最隐蔽:pydantic-settings默认.env文件里的变量不会覆盖已有的系统环境变量。如果你之前在shell里export OPENAI_API_KEY过,那么.env里怎么改都没用。排查的时候第一反应print(settings.llm_api_key[:5])看看到底加载到没有。

4.2 Python版本与依赖的兼容性问题

我强烈建议使用pyenv或conda固定Python 3.11版本。

我试过在Python 3.12下安装pydantic2.6系列,某些依赖需要编译rust库,在一台老机器上直接编译失败。后来切到3.11,三分钟搞定。

LangChain的版本锁定更是一条血泪经验:不要用pip install langchain然后指望它自动给你拉一个能跑的版本。不同大版本之间的API差异极大,进入2024年之后langchain和langchain-core的版本必须保持配套。我的做法是直接pip freeze之后把关键版本号写死在pyproject.toml里。

pip freeze | grep -E "(langchain|openai|pydantic)"

4.3 JSON输出解析失败的多种形态

这是最让人头秃的一类问题,因为模型每次失败的方式都可能不同。我归纳成三种:

  1. 模型输出的JSON被Markdown包裹:输出是```json\n{...}\n```——PydanticOutputParser的parse方法在某些版本里不能处理这种带代码块的情况。解决就是safe_parse_ai_response里的正则提取。
  2. 字段缺失:模型没生成suggested_questions,即使你在Field里写了description。解决:给所有非必填字段设置default=None或default_factory,保证解析器能字段对齐。
  3. 多余的逗号:JSON末尾多了个逗号,或者中文字符串没转义。底线兜底就是json.loads失败以后走强制装填路线。

建议测试时多跑几个不同类型的输入(短问题、长问题、带代码的问题、含有JSON关键词的问题),看解析器的鲁棒性。

4.4 中文显示和日志编码

在Windows终端上,控制台输出中文乱码是一个经典问题。

# 如果出现 UnicodeDecodeError 或控制台乱码 import sys sys.stdout.reconfigure(encoding="utf-8")

Windows下运行建议在代码入口加这个。macOS和Linux一般没这个问题。

日志方面,我用logging搭配RotatingFileHandler,防止logs/app.log无限膨胀。这个属于老生常谈,但真出问题的时候能省很多事。

4.5 Prompt注入格式指令后模型反而退化的处理

有的模型在收到复杂的JSON Schema之后,虽然在结构上更准了,但回复内容本身变呆板了、像是被格式绑架了。

我的处理经验是给FORMAT_INSTRUCTIONS降级。比如不把完整的Pydantic Schema塞进去,而是改成一个精简版的示例:

FORMAT_EXAMPLE = """{ "reply": "给用户的回复", "related_topics": [], "needs_clarification": false, "suggested_questions": null }"""

实测对某些模型效果反而更好。结构信息不一定越完整越有效,模型能力不同,你要快速试出当前模型的最佳输入形态。

5. 项目进度自检与下一步规划

本阶段做完,我给自己列了个验收清单:

  • [x] 项目目录结构清晰,配置独立成文件,密钥不入库
  • [x] 模型封装层支持切换兼容OpenAI协议的各类服务
  • [x] Prompt模板支持带格式约束的调用
  • [x] 输出解析器具备兜底策略,异常不吞用户可见的回复
  • [x] 命令行交互可跑通,结束进程干净

这一套做完,我的体会是:阶段1的核心价值不是"能跑起来"这个结果,而是把以后每次写功能时都要做的那层基础交互,提前打磨成了稳定的组件。后面加记忆、加工具、加UI,每个阶段都只关注增量部分,而不是每次都要回头补地基。

最后再送一个实用技巧:把safe_parse_ai_response之外再加一层LLMResponseLogger,把每次请求和响应都落一份原始日志,不管后面做评测还是调试,有了原始数据你才能定位问题。我在做agent系列的时候,这句话真的救了我好几次。

这一篇讲清楚了阶段1的初始化与Model I/O,下一篇我会接着做阶段2——对话记忆。届时会涉及LangChain的记忆组件选型、上下文窗口管理、多轮对话的摘要策略。等阶段3的工具调用做完,这个个人AI助手的完整度就会开始超出预期了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询