1. 项目概述:构建一个智能租房推荐Agent
去年参与山东大学实训项目时,我们团队接到了一个很有意思的挑战——开发一个基于大数据的智能租房推荐系统。不同于传统的推荐系统,我们需要构建的是一个能够理解用户需求、进行多轮对话、并自主调用各种工具来完成复杂任务的智能体(Agent)。
这个智能体的核心能力包括:
- 理解自然语言描述的租房需求
- 管理多轮对话上下文
- 自主决定何时以及如何调用各种工具(如房源数据库、地图API、价格分析工具等)
- 将工具返回的结果整合成用户友好的回复
我们选择使用TypeScript作为开发语言,主要考虑到:
- 类型系统能在复杂系统中提供更好的开发体验和代码质量
- 与前端生态(如React)更好的集成可能性
- 团队成员对JavaScript生态都比较熟悉
技术栈方面,我们基于LangChain框架构建核心Agent能力,它提供了对话管理、工具调用等常用功能的封装,让我们能快速搭建原型,同时保留了足够的灵活性进行定制开发。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 项目结构与设计思路
2.1 目录结构设计
良好的项目结构是可持续开发的基础。我们参考了Claude Code的Agent框架设计,最终确定的目录结构如下:
code复制house-recommend/
├── data/ # 数据目录
│ └── mockListings.json # 模拟房源数据
├── docs/ # 文档目录
│ ├── architecture.md # 架构文档
│ └── prompt_templates_v1.md # 提示模板
├── src/ # 源代码目录
│ ├── agent/ # 智能体相关代码
│ ├── config/ # 配置文件
│ ├── domain/ # 领域模型
│ ├── memory/ # 记忆系统
│ ├── runtime/ # 运行时
│ ├── tools/ # 工具定义
│ └── types/ # 类型定义
├── storage/ # 存储目录
├── .env.example # 环境变量示例
├── package.json # 依赖配置
└── tsconfig.json # TypeScript 配置
这样设计的考虑是:
- 模块化分离:将不同功能的代码放在独立的目录中,便于团队协作和维护
- 领域驱动设计:专门的domain目录用于存放与租房领域相关的模型和业务逻辑
- 可扩展性:memory和tools目录为未来添加更多记忆类型和工具预留了空间
- 文档与数据分离:避免测试数据污染代码库,也便于数据管理
提示:在实际项目中,我们后来发现config目录更适合放在src外层,因为配置可能同时影响构建和运行时。这是我们在项目中期做的一个结构调整。
2.2 核心类设计
我们定义了ReactRentalAgent作为智能体的主类,它的核心职责包括:
- 管理对话流程
- 协调工具调用
- 处理用户输入和生成回复
typescript复制export class ReactRentalAgent {
// 定义智能体模型
private readonly model = new ChatOpenAI({
apiKey: env.OPENAI_API_KEY,
model: env.OPENAI_MODEL,
temperature: 0.2,
});
// 工具集合
private readonly tools: Tool[];
constructor(tools: Tool[]) {
this.tools = tools;
}
// 主运行方法
async run(input: string): Promise<AgentExecutionResult> {
// 实现细节稍后展示
}
}
温度参数(temperature)设为0.2是为了在创造性和确定性之间取得平衡——我们希望推荐结果有一定变化空间,但不能太过随机。
3. 记忆系统实现
3.1 对话记忆管理
智能体需要记住对话历史才能进行有上下文的多轮对话。我们实现了ConversationMemory类来管理短期记忆:
typescript复制import { AIMessage, BaseMessage, HumanMessage, ToolMessage } from "@langchain/core/messages";
export class ConversationMemory {
private readonly messages: BaseMessage[] = [];
// 初始化对话
seed(input: string) {
this.messages.push(new HumanMessage(input));
}
// 添加AI回复
appendAssistant(message: AIMessage) {
this.messages.push(message);
}
// 添加工具返回结果
appendToolObservation(observation: ToolMessage) {
this.messages.push(observation);
}
// 获取完整对话历史
getMessages(): BaseMessage[] {
return [...this.messages];
}
}
这个设计有几个关键点:
- 消息类型区分:明确区分用户输入(HumanMessage)、AI回复(AIMessage)和工具返回(ToolMessage)
- 不可变性:getMessages返回副本而非原始数组,避免外部修改内部状态
- 简单接口:只暴露必要的方法,保持实现细节私有
3.2 记忆系统的实际应用
在实际对话中,记忆系统的工作流程如下:
- 用户说:"我想在市中心租个两居室"
- 系统创建新对话,seed()方法添加第一条消息
- Agent决定需要查询房源,调用工具
- 工具返回结果被appendToolObservation()添加到记忆
- Agent生成回复:"找到5套符合要求的房源..."并通过appendAssistant()添加
- 用户继续:"只要带电梯的"
- 整个对话历史被getMessages()获取,用于生成下一轮回复
这种设计使得Agent能够基于完整上下文做出决策,而不是仅看到最新的一条消息。
4. 主循环与工具调用
4.1 主循环实现
Agent的核心是一个循环执行"思考-行动"的过程,直到完成任务或达到最大迭代次数:
typescript复制async run(input: string): Promise<AgentExecutionResult> {
const memory = new ConversationMemory();
memory.seed(input);
const steps: AgentExecutionResult["steps"] = [];
const toolEnabledModel = this.model.bindTools(this.tools);
for (let iteration = 0; iteration < env.AGENT_MAX_ITERATIONS; iteration += 1) {
const response = (await toolEnabledModel.invoke(this.buildMessages(memory))) as AIMessage;
memory.appendAssistant(response);
// 如果没有工具调用,返回最终结果
if (!response.tool_calls || response.tool_calls.length === 0) {
return {
output: this.getMessageText(response),
steps,
};
}
// 处理每个工具调用
for (const toolCall of response.tool_calls) {
const tool = this.tools.find((candidate) => candidate.name === toolCall.name);
if (!tool) {
throw new Error(`Tool ${toolCall.name} is not registered.`);
}
const observation = await tool.invoke(toolCall.args);
steps.push({
toolName: toolCall.name,
observation: typeof observation === "string" ? observation : JSON.stringify(observation),
});
memory.appendToolObservation(
new ToolMessage({
tool_call_id: toolCall.id ?? toolCall.name,
content: typeof observation === "string" ? observation : JSON.stringify(observation),
}),
);
}
}
throw new Error(`Agent stopped after ${env.AGENT_MAX_ITERATIONS} iterations without a final answer.`);
}
4.2 工具调用机制
工具调用是Agent扩展能力的关键。我们使用LangChain提供的工具接口,使得添加新工具非常简单:
- 工具定义:每个工具需要提供名称、描述和实现函数
- 工具绑定:通过bindTools()方法将工具集合与模型关联
- 自动选择:模型根据当前对话上下文自动决定是否需要调用工具及调用哪个工具
例如,一个简单的房源查询工具可能这样定义:
typescript复制const listingSearchTool = new DynamicTool({
name: "search_listings",
description: "Search for rental listings based on criteria",
func: async (input: string) => {
const criteria = JSON.parse(input);
const results = await database.searchListings(criteria);
return JSON.stringify(results);
},
});
4.3 循环终止条件
我们设置了AGENT_MAX_ITERATIONS(默认10次)来防止无限循环。在实际测试中发现:
- 简单查询通常1-3轮就能完成
- 复杂需求(如"先看价格区间,再筛选朝向,最后考虑交通")可能需要5-7轮
- 超过10轮通常意味着对话陷入死循环或需求不明确
注意:这个值需要根据实际应用场景调整。对于更复杂的场景,可能需要增加限制,同时添加超时机制作为第二重保障。
5. 开发经验与优化方向
5.1 遇到的典型问题及解决
问题1:工具调用混乱
初期发现Agent有时会同时调用多个矛盾的工具。例如同时查询"市中心"和"郊区"的房源。
解决方案:
- 优化工具描述,使其更精确
- 在prompt中添加强调:"每次只解决一个最关键的筛选条件"
- 添加后处理逻辑,检查工具调用的合理性
问题2:记忆丢失
在长对话中,Agent有时会"忘记"早期的关键要求。
解决方案:
- 实现关键信息提取功能,标记用户的核心需求
- 在记忆系统中添加优先级机制,重要信息更不容易被"遗忘"
- 考虑实现摘要功能,定期生成对话摘要
5.2 性能优化经验
- 批量工具调用:当多个工具可以并行调用时,使用Promise.all来提升效率
- 缓存机制:对耗时的工具调用结果进行缓存,特别是静态数据查询
- 延迟加载:非核心工具按需加载,减少初始化时间
- 流式响应:对长耗时操作实现流式输出,提升用户体验
5.3 未来扩展方向
- 长期记忆:实现用户偏好的记忆和学习功能
- 多Agent协作:引入专门处理价格、位置、房型等不同维度的Agent
- 验证机制:对工具返回的结果进行可信度验证
- 解释功能:让Agent能够解释推荐的理由,增强可信度
这个项目让我们深刻体会到,构建一个实用的AI Agent不仅需要技术实现,更需要深入理解领域需求,并在灵活性和可控性之间找到平衡。特别是在租房这种涉及重大生活决策的场景,推荐的可靠性和可解释性尤为重要。
