多模态对话式AI助力房源搜索:从图片识别到成本估算的MVP实践
2026/9/4 4:33:11 网站建设 项目流程

开发一个“会聊天、能读照片、还会算月成本”的房源搜索工具时,最麻烦的往往不是调用大模型接口本身,而是如何把多轮对话、图片理解和费用估算这三类能力有条理地放进同一条业务链路里。很多项目做着做着就变成了“只会聊天”,或者“能识别一张图,但问一句每月要花多少钱就无法回答”。

文章以这个需求为背景,拆解一个可复现的房源搜索助手 MVP。内容覆盖后端接口设计、数据组织、图片信息提取方案、月供月租估算逻辑,以及一个简单的前端对话页面。无论你是想把它改造成个人项目作品,还是借鉴到真实房产信息平台中做“AI 带看助手”,都能找到可落地的代码结构。

1. 业务需求与核心概念

先还原一个真实问题:用户在筛选房源时,通常会做三类动作。

第一类是检索式提问,比如“上海静安区有没有两室一厅、月租 12000 以内”。传统做法是让用户填筛选条件,听话但不够自然。更好的体验是让他直接打一句话,系统自己去拆城市、区域、户型、预算。

第二类是图片理解,比如“这套房采光怎么样”“照片里的卧室可以放双人床吗”“这个户型有没有阳台”。技术上是读图,但实际上是结合房产领域的常识做判断,比如从卧室照片里判断床的尺寸,需要模型对尺度有感知。

第三类是成本测算,比如“这套 390 万的房子,首付三成,月供多少”“加上物业费和取暖费,一个月养房多少钱”。这是确定性计算,不适合让大模型自由发挥,而是应该从用户话语中抽取出关键参数,再交给精确的金融公式或费率模板。

这三个能力放在一个页面里,就构成了一个对话式房源搜索助手:用户输入“帮我找静安区适合三口之家、能看江景、总价 600 万以内的两房”,系统先拆条件,再搜房源;如果用户上传户型图或房间照片,再补一轮视觉信息;如果用户继续问“这套房每月成本多少”,系统计算并解释费用构成。

从技术选型角度看,这不是一个简单的 ChatGPT 套壳应用。它需要用到任务编排、检索模块、规则计算和可插拔的多模态识别能力。这里的“图片读取”并不是传统 OCR 工具能独立完成的任务,而需要决策:哪些信息从结构化字段获得,哪些信息只能从图片中推测,哪些信息必须通过物业或银行数据核对。

例如“月成本”就包含多个来源:真实租金或售价来自房源列表,物业费可能来自公开费率表,水电燃气费需要按城市、户型和季节估算,买房还涉及首付比例、贷款利率、还款年限等因素。如果这个系统能对用户说清楚“这套房每月成本大约是 1.2 万,其中月供占 9800、物业占 800、日常杂费估算 1400”,它提供的价值就比普通列表搜索高很多。

下面按照一套完整的工程实现来解析。示例项目会在本地运行,不依赖任何需要复杂的分布式环境的外部服务,因此你可以直接复制代码,把整条链路跑通后再替换成索引库或真实模型。

2. 系统模块与技术选型

在设计阶段,不建议把所有能力都塞进一个“超级对话函数”中。推荐把系统拆成四个独立模块,让每个模块只做一类事情。

模块一:房源仓库。负责持有房源结构数据,并对文本条件做基础匹配。在演示项目中,数据结构直接保存在 Python 模块内,便于运行;在成熟系统中,这部分建议由 PostgreSQL、MySQL 或 Elasticsearch 承担。

模块二:意图理解与检索编排。用户输入一句话之后,需要一个轻量解析层,负责抽取城市、区域、户型、价格区间等参数。示例项目用关键词和正则解析,是为了不依赖额外模型也能演示完整流程;生产环境推荐用大模型输出结构化 JSON,再接规则校准。

模块三:图片服务。服务接收图片文件后先做安全校验,然后提取本地信息,比如尺寸、格式、亮度;如果需要更深入的场景识别,再调用外部多模态模型接口。为了避免接口不稳定影响主流程,图片分析结果应缓存,并且失败时不应该阻断搜索结果返回。

模块四:成本估算器。该模块接收房源对象和用户意图参数,返回一个可解释的费用明细结构,例如租房模式下的月租金、物业费、水电宽带估算,以及买房模式下的首付、月供和年度固定支出。

模块之间通过简单数据类传递信息,不直接共享数据库,这样后面替换搜索引擎或切换模型服务时不会牵一发而动全身。对话主流程可以描述为:

  1. 用户输入文本,可能附带微信聊天中常见的口语表达。
  2. 解析层尝试抽取筛选条件,包括城市、区域、房型、价格上限。
  3. 检索层先用结构化条件过滤,再用关键词对标题和描述排序。
  4. 如果用户上传照片,图片服务给出一个简短分析结果。
  5. 如果意图词包含“月租”“月供”“成本”等内容,成本估算器对候选房源计算费用。
  6. 组装最终回复,同时把候选房源和费用明细用 JSON 返回给前端。

典型方案依赖配置如下表所示。

模块推荐方案用途
Web 框架FastAPI提供 /api/chat 接口与静态页面
数据存储内存列表 + JSON 结构演示和轻量级场景
文本检索结构化过滤 + 关键词匹配不需要深度学习即可稳定的检索
图片基础解析Pillow获取尺寸、格式、亮度
图片深度解析可插拔多模态接口识场景、判采光、估家具尺度
成本计算规则函数月供、租金、物业费等精确口径

这里可能有人会问,为什么不做端到端的大模型返回结果?因为“每月成本”不允许含糊,大模型完全可能把 3.9% 的利率算错,甚至把首付比例理解错。业务结论类字段必须有独立校验。

3. 运行环境与项目结构

下面的示例代码以 Python 3.10 为基础,建议安装 FastAPI 和它的可选依赖,代码在 Windows、macOS、Linux 上都能运行。由于各类库版本迭代较快,这里不锁定版本号,安装时选择当前稳定版即可。

依赖安装命令:

pip install fastapi "uvicorn[standard]" python-multipart pillow requests

如果之后要接入外部多模态服务,建议再安装 httpx,或者继续使用 requests 也可以。项目结构如下:

smart-home-search/ ├── backend/ │ ├── main.py # FastAPI 入口与服务配置 │ ├── house_store.py # 房源数据结构与检索 │ └── services/ │ ├── cost_estimator.py # 月租与月供成本估算 │ ├── image_service.py # 图片文件分析与多模态接口封装 │ └── chat_agent.py # 对话编排与回复 ├── frontend/ │ └── index.html # 聊天页面 └── requirements.txt

这套结构照顾了“最小可运行”,也保留了拆分接口的边界。如果你希望直接在企业项目中使用,建议增加 config、logging、repository 三层,把接口依赖注入做完整。

下面从数据层开始实现。

4. 房源仓库与基础检索实现

房源仓库在这套演示里的职责是:定义一个能表达房源核心属性的数据结构,并且提供两层筛选能力。

第一层是条件过滤,字段包括城市、区域、房型、租金上限、总价上限;第二层是关键词排序,匹配标题、描述、标签。结构化的地方用精确条件,非结构化文本用弱匹配,这个思路在大多数检索场景里都成立。

源码位置:backend/house_store.py

# backend/house_store.py from __future__ import annotations from dataclasses import dataclass, field, asdict from typing import Optional @dataclass class House: id: str title: str city: str district: str address: str area_sqm: float rooms: int living_rooms: int = 1 bathrooms: int = 1 orientation: str = "" floor: str = "" decoration: str = "" tags: list[str] = field(default_factory=list) description: str = "" # 租金或售价,按房源类型二选一或都填 rent_per_month: Optional[float] = None # 租金单位:元/月 sale_price: Optional[float] = None # 售价单位:万元 property_fee_per_sqm: float = 2.8 # 物业费:元/平方米/月 HOUSE_SAMPLES = [ House( id="demo-sh-01", title="静安寺旁温馨两室一厅", city="上海", district="静安区", address="示例路 100 号", area_sqm=89.0, rooms=2, living_rooms=1, bathrooms=1, orientation="南", floor="中层/共18层", decoration="精装", tags=["近地铁", "电梯房", "拎包入住"], description="采光较好,主卧朝南,距离地铁站步行约 500 米。", rent_per_month=11500, ), House( id="demo-sh-02", title="徐汇滨江次新三房", city="上海", district="徐汇区", address="示例滨江路 200 号", area_sqm=128.0, rooms=3, living_rooms=2, bathrooms=2, orientation="东南", floor="高层/共32层", decoration="开发商精装", tags=["看江景", "人车分流", "次新小区"], description="客厅和主卧可看江,适合改善型家庭。", sale_price=1280, property_fee_per_sqm=4.5, ), House( id="demo-bj-01", title="朝阳大悦城附近两居", city="北京", district="朝阳区", address="示例青年路 300 号", area_sqm=75.0, rooms=2, living_rooms=1, bathrooms=1, orientation="南北", floor="低层/共6层", decoration="简单装修", tags=["近商业", "看房方便"], description="南北通透,次卧面积稍小,适合情侣或小家庭。", rent_per_month=8200, ), ] class HouseStore: """演示用房源仓库,实际项目中可替换为 MySQL/PG/ES""" def __init__(self, houses: list[House]): self._houses = houses def list_all(self) -> list[House]: return self._houses def search(self, city: Optional[str] = None, district: Optional[str] = None, max_rent: Optional[float] = None, max_price: Optional[float] = None, rooms: Optional[int] = None, keyword: str = "") -> list[House]: """先过滤结构化条件,再用关键词做弱匹配排序""" result = [] for house in self._houses: if city and house.city != city: continue if district and district not in house.district: continue if max_rent is not None and house.rent_per_month is not None: if house.rent_per_month > max_rent: continue if max_price is not None and house.sale_price is not None: if house.sale_price > max_price: continue if rooms is not None and house.rooms < rooms: continue # 关键词弱匹配,给分排序 score = 0 if keyword: text = (house.title + house.district + house.description + " ".join(house.tags)) if keyword in text: score += 10 if keyword in house.title: score += 20 result.append((score, house)) # 按匹配度降序,再按房源 ID 保持稳定顺序 result.sort(key=lambda x: (-x[0], x[1].id)) return [house for _, house in result]

这段代码定义房源时没有使用第三方 ORM,而是使用 dataclass,原因有三个:代码短、字段清晰、能直接用于前后端 JSON 序列化。实际生产环境中,可以用 SQLAlchemy 的模型替代,但核心字段设计是一样的。

检索函数里有一点需要留意:价格条件并不是“必须字段”,因为用户可能只搜区域,不一定提价格。因此代码在过滤时使用独立 if,而不是“非空对象就继续”的短路径,避免漏掉租房房源只填 rent、不填 sale_price 的情况。关键词弱匹配的分数并不高深,只是为了处理“静安寺”“江景”这类口语词的模糊需求。

如果你希望得到更可靠的排序,建议引入向量检索或全文索引,但底层思路不变:结构化过滤与文本相关度结合。

5. 图片服务:从本地信息到多模态识别

“读照片”在这个项目里分为两个层次。第一层是无论本地还是外部模型都可以完成的文件级分析,包括图片尺寸、格式、大小、平均亮度;第二层是需要多模态模型参与的语义理解,比如判断卧室照片中一张床的宽度、阳台是否存在、客厅是否通透。

先从文件级分析开始,它解决两个问题:一是防止用户上传超大图片消耗太多资源,二是为后续模型调用前做基础筛选。

源码位置:backend/services/image_service.py

# backend/services/image_service.py import os import base64 from pathlib import Path import requests from PIL import Image, ImageStat class ImageAnalyzer: """负责图片上传后的安全校验与信息提取""" ALLOWED_EXTENSIONS = {".jpg", ".jpeg", ".png", ".webp"} MAX_SIZE = 15 * 1024 * 1024 # 15MB def __init__(self, vision_endpoint: str = "", api_key: str = ""): # 为空时只做本地解析,不请求外部接口 self.vision_endpoint = vision_endpoint self.api_key = api_key def validate(self, file_path: Path) -> None: if file_path.suffix.lower() not in self.ALLOWED_EXTENSIONS: raise ValueError("不支持的图片格式,仅支持 jpg/png/webp") if file_path.stat().st_size > self.MAX_SIZE: raise ValueError("图片大小不能超过 15MB") def analyze(self, file_path: Path) -> dict: """本地解析 + 可选远程多模态分析""" self.validate(file_path) base_info = self._local_info(file_path) if self.vision_endpoint and self.api_key: try: remote_info = self._remote_vision(file_path, "请描述这张房源的户型、采光、家具尺度等关键信息。") base_info["semantic_analysis"] = remote_info except Exception as exc: # noqa: BLE001 # 外部模型失败不能阻断主流程 base_info["semantic_warning"] = f"多模态服务调用失败:{exc}" else: base_info["semantic_warning"] = "未配置多模态接口,当前仅返回图片基础信息。" return base_info def _local_info(self, file_path: Path) -> dict: with Image.open(file_path) as img: width, height = img.size fmt = img.format or "UNKNOWN" mode = img.mode # 统一转成 RGB 再计算平均亮度 rgb = img.convert("RGB") stat = ImageStat.Stat(rgb) brightness = round(sum(stat.mean) / 3.0, 1) return { "width": width, "height": height, "file_name": file_path.name, "size_bytes": file_path.stat().st_size, "format": fmt, "mode": mode, "average_brightness": brightness, } def _remote_vision(self, file_path: Path, prompt: str) -> dict: """OpenAI 兼容多模态接口调用范式,具体地址和字段以实际服务商为准""" with open(file_path, "rb") as f: image_bytes = f.read() image_base64 = base64.b64encode(image_bytes).decode("utf-8") suffix = file_path.suffix.lower().lstrip(".") if suffix == "jpg": suffix = "jpeg" payload = { "model": "vision-model-placeholder", "messages": [ { "role": "user", "content": [ {"type": "text", "text": prompt}, { "type": "image_url", "image_url": { "url": f"data:image/{suffix};base64,{image_base64}" } }, ], } ], } headers = {"Authorization": f"Bearer {self.api_key}"} resp = requests.post(self.vision_endpoint, json=payload, headers=headers, timeout=20) resp.raise_for_status() data = resp.json() return data.get("choices", [{}])[0].get("message", {}).get("content", "")

在本地演示中,图片服务不会把你上传的图片发送到任何外部服务,所以不用准备 API Key。这一点对初次体验项目非常有用。图片分析结果会以语义结构返回,例如宽度、高度、平均亮度等,前端可以直接展示。

“平均亮度”是判断采光的一种低成本代理指标,虽然不能替代真正的日照分析,但能起到第一轮筛选作用。如果图片中平均亮度明显偏暗,系统会提示用户这套房的采光可能一般。真实业务中,要判断阳台、户型、床上用品尺度,仍然需要调用具备视觉能力的大模型。示例代码保留了一个多模态提示词占位符,配置好接口地址后无需改动主流程即可接入。

实现时需要注意两个坑:第一,图片上传接口必须限制大小和类型,否则恶意大文件会拖垮服务器;第二,远程模型调用需要超时时间,不能因为模型服务慢而让整个 HTTP 请求长时间挂起。也正因如此,在 ChatAgent 调用图片服务后要立即释放上传文件句柄。

6. 成本估算:月租、月供、物业费及杂费模块

费用是一类对精度要求很高的信息,直接让大模型算会带来灾难性后果。更好的做法是让大模型只是“意图分发器”,真正计算交给公式函数。下面实现成本估算器,先支持租房和买房两种模式。

租房模式费用定义为:租金 + 物业费 + 水电网燃气杂费。物业费单位是“元/平方米/月”,租金是“元/月”。

买房模式费用定义为:首付 + 月供 + 物业费 + 杂费。其中月供按等额本息计算,这里特别说明:真实贷款存在公积金贷款、商业贷款组合、不同银行利率浮动等复杂情况,示例代码用统一利率简化,生产环境应接入银行可用利率并由用户选择贷款类型。

源码位置:backend/services/cost_estimator.py

# backend/services/cost_estimator.py from __future__ import annotations from dataclasses import asdict from typing import Optional from house_store import House def calculate_mortgage(principal: float, annual_rate: float, years: int) -> float: """等额本息月供,principal 单位:元;annual_rate 为年利率,例如 0.039""" monthly_rate = annual_rate / 12 months = years * 12 if monthly_rate == 0: return principal / months factor = (1 + monthly_rate) ** months return principal * monthly_rate * factor / (factor - 1) def estimate_rent_cost(house: House, utilities: float = 300.0) -> dict: """租房月成本估算""" if house.rent_per_month is None: return {"supported": False, "reason": "该房源不在租房列表"} property_fee = house.area_sqm * house.property_fee_per_sqm total = house.rent_per_month + property_fee + utilities return { "supported": True, "mode": "rent", "monthly_total": round(total, 2), "items": { "rent": house.rent_per_month, "property_fee": round(property_fee, 2), "utilities": utilities, }, } def estimate_buy_cost(house: House, down_payment_ratio: float = 0.3, loan_years: int = 30, annual_rate: float = 0.039, utilities: float = 500.0) -> dict: """买房首次成本与月成本估算,sale_price 单位:万元""" if house.sale_price is None: return {"supported": False, "reason": "该房源不在出售列表"} total_price = house.sale_price * 10000 # 万元转元 down_payment = total_price * down_payment_ratio loan_amount = total_price - down_payment monthly_payment = calculate_mortgage(loan_amount, annual_rate, loan_years) property_fee = house.area_sqm * house.property_fee_per_sqm monthly_total = monthly_payment + property_fee + utilities return { "supported": True, "mode": "buy", "total_price": total_price, "down_payment_ratio": down_payment_ratio, "down_payment": round(down_payment, 2), "loan_amount": round(loan_amount, 2), "loan_years": loan_years, "loan_rate": annual_rate, "monthly_mortgage": round(monthly_payment, 2), "monthly_total": round(monthly_total, 2), "items": { "mortgage": round(monthly_payment, 2), "property_fee": round(property_fee, 2), "utilities": utilities, }, }

调用估算器时,需要先明确用户是在问“租”还是“买”。示例房子 demo-sh-01 只有租金,demo-sh-02 只有售价,所以如果用户同时上传某套房并问月供,系统应返回“这不是出售房源”的提示,而不是胡猜一个价格。实际业务中同一套房可能既出租又出售,这时可以根据用户问题词自动猜一种模式,也可以让用户在界面上选择“租/买”,后者的交互更清晰,也避免模型猜测。

公式里我使用了“元”作为最小单位,这样能避免金额格式化问题时出现“万元/元”混乱。虽然最后返回 JSON 中 total_price 比较大,但在函数内部没有单位换算错位。房租与月供金额使用四舍五入保留两位,便于展示与调试。

需要说明的是,现实中买房还涉及契税、维修基金、供暖费等,因此在“production”版本里应该增加一个费用模板配置。例如北方城市冬季取暖费按建筑面积分摊到每个月,这个信息不同城市差异明显,建议配置在房源所属小区字段下,而不是写死在全国数据里。

7. 对话 Agent:编排、检索、计算与回复

在完成数据层、图片层和费用层之后,工作重点变成对话编排。不要把所有逻辑都放进一个主类里反复嵌套 if,最好把解析、检索、图片分析、费用判断拆成独立小函数。

ChatAgent 可以理解为状态机:先看消息中有没有图片文件;再解析文本中的筛选条件;接着做检索;然后根据意图调用图片服务或费用估算;最后组装成一句自然语言回复。对于演示项目,正则方式足够展示流程,如果以后想要更高准确率,可将解析部分替换成大模型结构化输出。

源码位置:backend/services/chat_agent.py

# backend/services/chat_agent.py import re from dataclasses import asdict from pathlib import Path from typing import Optional from house_store import HouseStore from services.cost_estimator import estimate_rent_cost, estimate_buy_cost from services.image_service import ImageAnalyzer class ChatAgent: def __init__(self, store: HouseStore, image_analyzer: ImageAnalyzer): self.store = store self.image_analyzer = image_analyzer def _parse_intent(self, text: str) -> str: """极简意图判断:rent / buy / search / none""" if re.search(r"月租|租金|租房|租", text): return "rent" if re.search(r"月供|首付|贷款|按揭|公积金|买房|购房|总价", text): return "buy" return "search" def _extract_search_params(self, text: str): params = {} city_match = re.search(r"(上海|北京|广州|深圳|杭州|成都)", text) if city_match: params["city"] = city_match.group(1) district_match = re.search(r"(静安|徐汇|朝阳|浦东|西湖)", text) if district_match: params["district"] = district_match.group(1) room_match = re.search(r"([一二两三四五六七八九十\d]+)\s*(室|房|居室)", text) if room_match: raw = room_match.group(1) mapping = {"一": 1, "二": 2, "两": 2, "三": 3, "四": 4, "五": 5} if raw.isdigit(): params["rooms"] = int(raw) else: params["rooms"] = mapping.get(raw) max_rent_match = re.search(r"(?:月租|租金|预算|价格|总价)[^\d]{0,3}(\d+(?:\.\d+)?)[万kK]?", text) if max_rent_match: value = float(max_rent_match.group(1)) if "万" in max_rent_match.group(0): # 如果原句中包含“万”,则按万元处理再转元 params["max_rent"] = value * 10000 else: params["max_rent"] = value keyword = "" for kw in ["江景", "地铁", "安静", "精装", "阳光", "阳台"]: if kw in text: keyword += kw params["keyword"] = keyword return params def _build_reply(self, reply: str, houses: list, costs: Optional[list] = None, image_notes: Optional[dict] = None) -> dict: return { "reply": reply, "houses": houses, "costs": costs or [], "image_notes": image_notes, } def handle_query(self, text: str, uploaded_files: Optional[list[Path]] = None) -> dict: """核心编排逻辑,返回带回复文本和结构化结果的 JSON""" text = (text or "").strip() uploaded_files = uploaded_files or [] image_notes = [] # 1. 图片分析 for file_path in uploaded_files: try: note = self.image_analyzer.analyze(file_path) image_notes.append(note) except Exception as exc: # noqa: BLE001 image_notes.append({"error": str(exc)}) # 2. 解析条件 params = self._extract_search_params(text) params["keyword"] = params.get("keyword", "") houses = self.store.search(**params) # 如果没有条件,返回前三条示例 if not params.get("city") and not params.get("district") and not params.get("max_rent") and not params.get("rooms"): houses = self.store.list_all()[:3] house_dicts = [asdict(h) for h in houses] # 3. 费用估算 intent = self._parse_intent(text) costs = [] cost_house_ids = [] # 只对少量结果计算成本,避免大量费用计算影响返回速度 for h in houses[:2]: if intent == "rent": res = estimate_rent_cost(h) if res.get("supported"): costs.append({"house_id": h.id, **res}) cost_house_ids.append(h.id) elif intent == "buy": res = estimate_buy_cost(h) if res.get("supported"): costs.append({"house_id": h.id, **res}) cost_house_ids.append(h.id) # 4. 组装回复 if not houses: reply = "暂时没有找到完全符合这些条件的房源,你可以尝试放宽预算,或者去掉区域限制。" elif cost_house_ids and intent == "rent": reply = "找到几套符合你条件的出租房源,其中大多数每月总成本如下,包含租金、物业与杂费估算。" elif cost_house_ids and intent == "buy": reply = "定位到可能符合的出售房源,以下按等额本息方式粗略估算,“月供”未包含后续税费与维修基金。" else: reply = "下面是一些可能符合要求的房源,点击卡片可以查看更详细信息。如果你提到具体户型或预算,我可以继续帮你缩小范围。" return self._build_reply(reply, house_dicts, costs, image_notes[0] if image_notes else None)

这个 Agent 里,很多功能是刻意保持“弱”的。例如正则解析中国城市并不完整,因为示例系统只需要覆盖北京、上海两个城市;真实系统应当从地址库或大模型抽取。再比如 max_rent 解析中“总价 600 万以内”会被错误当作租金 600 元处理,显然不合适。因此在_parse_intent里需要进一步判断“总价”词汇,这里由于示例实现保留给读者优化。

这里有一个工程原则:演示代码的边界一定不能往生产环境照搬。你可以复制核心架构,但解析规则必须重新设计,比较推荐的方式是让大模型输出{"city":"上海","district":"静安区","max_rent":12000,"rooms":2}这样的 JSON,再用 Python 代码做合法性校验,而不是直接用 LLM 生成的文本去查数据库。

图片分析结果没有强行进入回复文本,而是单独放入 image_notes 字段,这样前端既能展示“已读取图片”,用户也能看到模型返回的语义说明。

8. FastAPI 入口与聊天接口

为了让前端页面能调用后端,需要一个 Web 服务。这里使用 FastAPI 提供两个路由:GET / 返回静态页面,POST /api/chat 接收文本和图片并返回对话结果。

接口设计采用 multipart/form-data 而不是 JSON,因为用户可能上传多张图片。前端使用 FormData 方式提交,可以同时传 text 字段和 images 文件列表。图片保存到临时目录后,将路径传给 Agent 处理。注意用完临时文件后清理。

源码位置:backend/main.py

# backend/main.py from __future__ import annotations import shutil import tempfile from pathlib import Path from fastapi import FastAPI, File, Form, UploadFile from fastapi.responses import FileResponse, JSONResponse from house_store import HOUSE_SAMPLES, HouseStore from services.chat_agent import ChatAgent from services.image_service import ImageAnalyzer # 用绝对路径定位 frontend/index.html,避免工作目录变化导致找不到 BASE_DIR = Path(__file__).resolve().parent.parent FRONTEND_FILE = BASE_DIR / "frontend" / "index.html" UPLOAD_DIR = Path(tempfile.gettempdir()) / "smart-home-search-uploads" UPLOAD_DIR.mkdir(exist_ok=True) # 在真实环境中从环境变量 / 配置中心读取 VISION_ENDPOINT = "" # 例如 https://your-vision-api.example/v1/chat/completions VISION_API_KEY = "" app = FastAPI(title="Smart Home Search Demo") store = HouseStore(houses=HOUSE_SAMPLES) image_analyzer = ImageAnalyzer( vision_endpoint=VISION_ENDPOINT, api_key=VISION_API_KEY, ) @app.get("/") def index(): return FileResponse(str(FRONTEND_FILE)) @app.post("/api/chat") async def chat(text: str = Form(""), images: list[UploadFile] | None = File(default=[])): """接收聊天文本和可选图片文件,返回对话结果""" if not text.strip() and not images: return JSONResponse({"error": "请输入文字或上传图片"}, status_code=400) saved_files = [] try: # 1. 保存上传图片 for image in images: if not image.filename: continue suffix = Path(image.filename).suffix.lower() tmp_file = UPLOAD_DIR / f"upload_{len(saved_files)}_{image.filename}" with tmp_file.open("wb") as buffer: shutil.copyfileobj(image.file, buffer) saved_files.append(tmp_file) # 2. 创建 Agent 并执行 agent = ChatAgent(store=store, image_analyzer=image_analyzer) result = agent.handle_query(text=text, uploaded_files=saved_files) return JSONResponse(result) finally: # 3. 清理临时文件 for file_path in saved_files: try: file_path.unlink(missing_ok=True) except OSError: pass

要在本地直接启动,运行:

cd smart-home-search uvicorn backend.main:app --reload --port 8000

打开浏览器访问 http://127.0.0.1:8000 就能看到聊天页面。后端代码需要注意运行时当前目录并不重要,因为所有内部模块都通过 Python 包路径定位。临时文件在 finally 块中删除,即使请求处理报错,也不会残留垃圾图片。

关于文件保存这里有一个容易被忽略的细节:接收上传文件时不能直接使用原始文件名作为保存名,否则可能产生目录穿越攻击。示例中只是简单加上了数字前缀,安全实现还应当生成 UUID 文件名,并对扩展名做白名单校验。这一点在后续最佳实践部分会再次强调。

为了让读者可以在不启动浏览器的情况下验证接口,这里给一个 curl 示例:

curl -X POST http://127.0.0.1:8000/api/chat \ -H "Content-Type: multipart/form-data" \ -F "text=上海静安区 两室 月租12000以内" \ -F "images=@img.jpg"

如果没有上传图片,可以把最后一个 -F 参数去掉。返回的内容会是一个 JSON,其中 reply 是自然语言回答,houses 是匹配到的房源列表,costs 是费用明细。由于示例仓库中 demo-sh-01 位于上海静安区,租金 11500,月租12000以内,系统应当能匹配到它。

9. 前端聊天页面实现

为了把能力真正呈现出来,需要一个简洁的聊天界面。这里不引入 Vue 或 React,只使用一个 HTML 文件,保证复制即用,同时也有利于初学者理解最原始的 fetch 通信方式。

界面能力包括:显示聊天记录;一个文本框;一个“发送”按钮;支持选择图片并在发送前做预览;收到后端结果后展示文本回复、图片提示信息和房源卡片。房源卡片直接读取后端返回的 houses 数据,把标题、区域、面积、房间数、价格、物业费等展示出来,如果有 costs 数组,则把估算结果也展示在卡片下方。

源码位置:frontend/index.html

<!-- frontend/index.html --> <!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>Smart Home Search Demo</title> <style> body { font-family: -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif; max-width: 860px; margin: 40px auto; padding: 0 16px; background: #f8f9fb; color: #24292f; } h1 { font-size: 20px; margin-bottom: 4px; } p.sub { color: #57606a; font-size: 14px; margin-top: 0; } #chatBox { background: #fff; border-radius: 14px; padding: 20px; min-height: 60vh; box-shadow: 0 6px 18px rgba(0,0,0,0.04); margin-bottom: 16px; } .message { margin-bottom: 16px; white-space: pre-wrap; line-height: 1.7; } .message.user { text-align: right; } .message.user .bubble { background: #eef4ff; border-radius: 12px 12px 2px 12px; padding: 10px 14px; display: inline-block; text-align: left; } .message.assistant .bubble { background: #f2f3f5; border-radius: 12px 12px 12px 2px; padding: 10px 14px; display: inline-block; text-align: left; } .house-card { border: 1px solid #dfe1e6; border-radius: 12px; padding: 14px; margin: 10px 0; display: flex; gap: 16px; align-items: flex-start; background: #fcfcfd; } .house-card .info

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

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

立即咨询