
开发一个“会聊天、能读照片、还会算月成本”的房源搜索工具时最麻烦的往往不是调用大模型接口本身而是如何把多轮对话、图片理解和费用估算这三类能力有条理地放进同一条业务链路里。很多项目做着做着就变成了“只会聊天”或者“能识别一张图但问一句每月要花多少钱就无法回答”。文章以这个需求为背景拆解一个可复现的房源搜索助手 MVP。内容覆盖后端接口设计、数据组织、图片信息提取方案、月供月租估算逻辑以及一个简单的前端对话页面。无论你是想把它改造成个人项目作品还是借鉴到真实房产信息平台中做“AI 带看助手”都能找到可落地的代码结构。1. 业务需求与核心概念先还原一个真实问题用户在筛选房源时通常会做三类动作。第一类是检索式提问比如“上海静安区有没有两室一厅、月租 12000 以内”。传统做法是让用户填筛选条件听话但不够自然。更好的体验是让他直接打一句话系统自己去拆城市、区域、户型、预算。第二类是图片理解比如“这套房采光怎么样”“照片里的卧室可以放双人床吗”“这个户型有没有阳台”。技术上是读图但实际上是结合房产领域的常识做判断比如从卧室照片里判断床的尺寸需要模型对尺度有感知。第三类是成本测算比如“这套 390 万的房子首付三成月供多少”“加上物业费和取暖费一个月养房多少钱”。这是确定性计算不适合让大模型自由发挥而是应该从用户话语中抽取出关键参数再交给精确的金融公式或费率模板。这三个能力放在一个页面里就构成了一个对话式房源搜索助手用户输入“帮我找静安区适合三口之家、能看江景、总价 600 万以内的两房”系统先拆条件再搜房源如果用户上传户型图或房间照片再补一轮视觉信息如果用户继续问“这套房每月成本多少”系统计算并解释费用构成。从技术选型角度看这不是一个简单的 ChatGPT 套壳应用。它需要用到任务编排、检索模块、规则计算和可插拔的多模态识别能力。这里的“图片读取”并不是传统 OCR 工具能独立完成的任务而需要决策哪些信息从结构化字段获得哪些信息只能从图片中推测哪些信息必须通过物业或银行数据核对。例如“月成本”就包含多个来源真实租金或售价来自房源列表物业费可能来自公开费率表水电燃气费需要按城市、户型和季节估算买房还涉及首付比例、贷款利率、还款年限等因素。如果这个系统能对用户说清楚“这套房每月成本大约是 1.2 万其中月供占 9800、物业占 800、日常杂费估算 1400”它提供的价值就比普通列表搜索高很多。下面按照一套完整的工程实现来解析。示例项目会在本地运行不依赖任何需要复杂的分布式环境的外部服务因此你可以直接复制代码把整条链路跑通后再替换成索引库或真实模型。2. 系统模块与技术选型在设计阶段不建议把所有能力都塞进一个“超级对话函数”中。推荐把系统拆成四个独立模块让每个模块只做一类事情。模块一房源仓库。负责持有房源结构数据并对文本条件做基础匹配。在演示项目中数据结构直接保存在 Python 模块内便于运行在成熟系统中这部分建议由 PostgreSQL、MySQL 或 Elasticsearch 承担。模块二意图理解与检索编排。用户输入一句话之后需要一个轻量解析层负责抽取城市、区域、户型、价格区间等参数。示例项目用关键词和正则解析是为了不依赖额外模型也能演示完整流程生产环境推荐用大模型输出结构化 JSON再接规则校准。模块三图片服务。服务接收图片文件后先做安全校验然后提取本地信息比如尺寸、格式、亮度如果需要更深入的场景识别再调用外部多模态模型接口。为了避免接口不稳定影响主流程图片分析结果应缓存并且失败时不应该阻断搜索结果返回。模块四成本估算器。该模块接收房源对象和用户意图参数返回一个可解释的费用明细结构例如租房模式下的月租金、物业费、水电宽带估算以及买房模式下的首付、月供和年度固定支出。模块之间通过简单数据类传递信息不直接共享数据库这样后面替换搜索引擎或切换模型服务时不会牵一发而动全身。对话主流程可以描述为用户输入文本可能附带微信聊天中常见的口语表达。解析层尝试抽取筛选条件包括城市、区域、房型、价格上限。检索层先用结构化条件过滤再用关键词对标题和描述排序。如果用户上传照片图片服务给出一个简短分析结果。如果意图词包含“月租”“月供”“成本”等内容成本估算器对候选房源计算费用。组装最终回复同时把候选房源和费用明细用 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_factorylist) description: str # 租金或售价按房源类型二选一或都填 rent_per_month: Optional[float] None # 租金单位元/月 sale_price: Optional[float] None # 售价单位万元 property_fee_per_sqm: float 2.8 # 物业费元/平方米/月 HOUSE_SAMPLES [ House( iddemo-sh-01, title静安寺旁温馨两室一厅, city上海, district静安区, address示例路 100 号, area_sqm89.0, rooms2, living_rooms1, bathrooms1, orientation南, floor中层/共18层, decoration精装, tags[近地铁, 电梯房, 拎包入住], description采光较好主卧朝南距离地铁站步行约 500 米。, rent_per_month11500, ), House( iddemo-sh-02, title徐汇滨江次新三房, city上海, district徐汇区, address示例滨江路 200 号, area_sqm128.0, rooms3, living_rooms2, bathrooms2, orientation东南, floor高层/共32层, decoration开发商精装, tags[看江景, 人车分流, 次新小区], description客厅和主卧可看江适合改善型家庭。, sale_price1280, property_fee_per_sqm4.5, ), House( iddemo-bj-01, title朝阳大悦城附近两居, city北京, district朝阳区, address示例青年路 300 号, area_sqm75.0, rooms2, living_rooms1, bathrooms1, orientation南北, floor低层/共6层, decoration简单装修, tags[近商业, 看房方便], description南北通透次卧面积稍小适合情侣或小家庭。, rent_per_month8200, ), ] 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(keylambda 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: fdata:image/{suffix};base64,{image_base64} } }, ], } ], } headers {Authorization: fBearer {self.api_key}} resp requests.post(self.vision_endpoint, jsonpayload, headersheaders, timeout20) 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_okTrue) # 在真实环境中从环境变量 / 配置中心读取 VISION_ENDPOINT # 例如 https://your-vision-api.example/v1/chat/completions VISION_API_KEY app FastAPI(titleSmart Home Search Demo) store HouseStore(housesHOUSE_SAMPLES) image_analyzer ImageAnalyzer( vision_endpointVISION_ENDPOINT, api_keyVISION_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_code400) saved_files [] try: # 1. 保存上传图片 for image in images: if not image.filename: continue suffix Path(image.filename).suffix.lower() tmp_file UPLOAD_DIR / fupload_{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(storestore, image_analyzerimage_analyzer) result agent.handle_query(texttext, uploaded_filessaved_files) return JSONResponse(result) finally: # 3. 清理临时文件 for file_path in saved_files: try: file_path.unlink(missing_okTrue) 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 imagesimg.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 langzh-CN head meta charsetUTF-8 / meta nameviewport contentwidthdevice-width, initial-scale1.0 / titleSmart 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