本文代码由Coding Agent辅助编写所使用Coding Agent包括Trae和Tencent Cloud CodebuddyLLM为deepseek v4 pro。0 介绍LangChainLangChain面向LLM应用开发设计它的贡献是将LLM模型与各种工具、数据源和业务逻辑连接起来以提高LLM应用特别是Agent应用主要通过LangGraph实现是他们目前的重点方向的开发效率。本文我们基于LangChain开发一个论文阅读助手本质是一个设定了特定system propmt的chatbot主要关注LangChain提供的高效工具实现以及代码特点。1 需求分析1.1 流程图1.2 技术栈PDF解析PyMuPDFLoaderlangchain_community.document_loaders切chunkRecursiveCharacterTextSplitterlangchain_text_splitters模型调用ChatOpenAI、OpenAIEmbeddingslangchain_openaipromptPromptTemplatelangchain_classic.prompts向量数据库FAISSlangchain_community.vectorstores检索RetrievalQAlangchain_classic.chains从这里也可以看出langchain框架高度覆盖了LLM应用开发中的常用工具并封装为了统一接口。2 环境配置conda create -n langchain python3.10 -y conda activate langchain pip install -u langchain pip install -U langchain-openai pip install openai pypdf pymupdf langchain-community faiss-cpu sentence-transformers langchain_text_splitters3 开发3.1 Config类dataclass class PaperReaderConfig: 论文阅读Agent配置 chunk_size: int 300 chunk_overlap: int 50 embedding_mode: str api embedding_api_url: str https://api.siliconflow.cn/v1 embedding_model_name: str BAAI/bge-large-zh-v1.5 embedding_api_key: Optional[str] field(defaultNone) local_model_name: str sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2 local_model_path: str ./embedding_model search_k: int 15 search_fetch_k: int 20 mmr_lambda: float 0.5 include_abstract: bool True abstract_pages: int 2 vector_store_path: str ./faiss_index llm_model_name: str deepseek-chat llm_api_base: str https://api.deepseek.com llm_api_key: Optional[str] field(defaultNone) temperature: float 0.1分块配置chunk_size和chunk_overlap表示文档切块的大小和每个切块间的重合数。此处重合数的意义是使得切分时被切断的句子出现在前一chunk的末尾和后一chunk的开头以期保持更好的语义完整性。嵌入模型配置embedding设计支持API调用和本地调用本文使用API调用其实是因为最早在WSL里下模型懒得配代理导致连不上hf就改成API调用了。此处使用的是硅基流动提供的bge-large-zh-v1.5模型是个免费API嵌入模型只要申请API就能用。API key此处写成占位符调试时可以硬编码在这里使用时建议从环境变量读取也可以从前端输入覆盖配置类。检索配置关于MMR的具体原理请参考论文《The use of MMR, diversity-based reranking for reordering documents and producing summaries》。这部分检索会根据相似度排序粗筛选search_fetch_k20个chunk在其中用MMR算法λ0.5最终筛选search_k15个chunk作为检索结果。include_abstract配置项用于向LLM上下文直接注入摘要为模型提供全文的感知。LLM配置使用API模型调用Deepseek V4。具体细节和嵌入模型一致。此处如果只是想实验可以去​​​​​​​OpenRouter找一些免费模型OpenRouter本身就支持OpenAI API可以直接嵌入到本文流程中。3.2 摘要注入class AbstractRetriever(BaseRetriever): 包装基础检索器自动注入论文摘要到检索结果中 base_retriever: BaseRetriever abstract_text: str include_abstract: bool def _get_relevant_documents(self, query: str, *, run_managerNone) - List[Document]: docs self.base_retriever.invoke(query) if self.include_abstract and self.abstract_text: prefix f[论文摘要/引言]\n{self.abstract_text}\n\n for doc in docs: doc.page_content prefix doc.page_content return docs这段代码重写了_get_relevant_documents方法以注入摘要通过base_retriever: BaseRetriever docs self.base_retriever.invoke(query)实际的检索工作仍由base_retriever完成检索完成后包装器向docs中注入摘要。3.3 主模块class PaperReaderAgent: 论文阅读Agent纯问答版本 QA_PROMPT PromptTemplate( template你是一位专业的论文阅读助手请基于提供的论文内容回答用户的问题。 如果问题的答案不在提供的论文内容中请直接说根据当前论文内容无法回答这个问题不要编造答案。 请保持回答的准确性和简洁性必要时可以引用原文内容。 论文内容: {context} 用户问题: {question} 请给出你的回答:, input_variables[context, question], ) def __init__(self, config: Optional[PaperReaderConfig] None): self.config config or PaperReaderConfig() self.vector_store: Optional[FAISS] None self.qa_chain: Optional[RetrievalQA] None self.embeddings None self.llm None self.abstract_text: str self._init_components() def _init_embedding_api(self): api_key self.config.embedding_api_key or os.environ.get(SILICONFLOW_API_KEY) if not api_key: raise ValueError( 未配置嵌入模型API Key请设置 config.embedding_api_key 或环境变量 SILICONFLOW_API_KEY ) logger.info(使用API嵌入模型: %s, self.config.embedding_model_name) self.embeddings OpenAIEmbeddings( modelself.config.embedding_model_name, openai_api_baseself.config.embedding_api_url, openai_api_keyapi_key, tiktoken_enabledFalse, check_embedding_ctx_lengthFalse, ) def _init_embedding_local(self): model_path os.path.abspath(self.config.local_model_path) if os.path.isdir(model_path) and os.listdir(model_path): logger.info(从本地路径加载嵌入模型: %s, model_path) else: if not os.environ.get(HF_ENDPOINT): os.environ[HF_ENDPOINT] https://hf-mirror.com logger.info(从HuggingFace加载嵌入模型: %s, self.config.local_model_name) model_path self.config.local_model_name self.embeddings HuggingFaceEmbeddings( model_namemodel_path, model_kwargs{device: cpu}, encode_kwargs{normalize_embeddings: True}, ) def _init_components(self): if self.config.embedding_mode api: self._init_embedding_api() else: self._init_embedding_local() llm_api_key self.config.llm_api_key or os.environ.get(DEEPSEEK_API_KEY) if not llm_api_key: raise ValueError( 未配置LLM API Key请设置 config.llm_api_key 或环境变量 DEEPSEEK_API_KEY ) logger.info(初始化LLM: %s, self.config.llm_model_name) self.llm ChatOpenAI( model_nameself.config.llm_model_name, temperatureself.config.temperature, openai_api_keyllm_api_key, openai_api_baseself.config.llm_api_base, ) def load_pdf(self, pdf_path: str) - None: if not os.path.exists(pdf_path): raise FileNotFoundError(fPDF文件不存在: {pdf_path}) logger.info(读取PDF: %s, pdf_path) loader PyMuPDFLoader(pdf_path) documents loader.load() documents [doc for doc in documents if doc.page_content.strip()] total_chars sum(len(doc.page_content) for doc in documents) logger.info(读取完成共%d页总字符数: %d, len(documents), total_chars) if self.config.include_abstract and documents: self.abstract_text \n\n.join( doc.page_content for doc in documents[: self.config.abstract_pages] ) logger.info(提取前%d页作为摘要 (%d字符), self.config.abstract_pages, len(self.abstract_text)) text_splitter RecursiveCharacterTextSplitter( chunk_sizeself.config.chunk_size, chunk_overlapself.config.chunk_overlap, separators[\n\n, \n, . , , ], length_functionlen, ) chunks text_splitter.split_documents(documents) logger.info(分块完成共%d个文本块, len(chunks)) logger.info(构建向量索引...) self.vector_store FAISS.from_documents(chunks, self.embeddings) logger.info(向量索引构建完成) self._build_qa_chain() self.save_vector_store() def _build_qa_chain(self): if not self.vector_store: raise ValueError(请先加载PDF文档) base_retriever self.vector_store.as_retriever( search_typemmr, search_kwargs{ k: self.config.search_k, fetch_k: self.config.search_fetch_k, lambda_mult: self.config.mmr_lambda, }, ) retriever AbstractRetriever( base_retrieverbase_retriever, abstract_textself.abstract_text, include_abstractself.config.include_abstract, ) self.qa_chain RetrievalQA.from_chain_type( llmself.llm, chain_typestuff, retrieverretriever, chain_type_kwargs{prompt: self.QA_PROMPT}, ) def ask(self, question: str) - str: if not self.qa_chain: raise ValueError(请先加载PDF文档) logger.info(处理问题: %s, question) response self.qa_chain.invoke({query: question}) return response[result] def save_vector_store(self): if self.vector_store: self.vector_store.save_local(self.config.vector_store_path) logger.info(向量索引已保存到: %s, self.config.vector_store_path) def load_vector_store(self): if not os.path.exists(self.config.vector_store_path): raise FileNotFoundError(f向量索引路径不存在: {self.config.vector_store_path}) self.vector_store FAISS.load_local( self.config.vector_store_path, self.embeddings, allow_dangerous_deserializationTrue, ) self._build_qa_chain() logger.info(向量索引已从%s加载, self.config.vector_store_path)下面逐方法介绍模块。3.3.1 prompt模板QA_PROMPT PromptTemplate( template你是一位专业的论文阅读助手请基于提供的论文内容回答用户的问题。 如果问题的答案不在提供的论文内容中请直接说根据当前论文内容无法回答这个问题不要编造答案。 请保持回答的准确性和简洁性必要时可以引用原文内容。 论文内容: {context} 用户问题: {question} 请给出你的回答:, input_variables[context, question], )此处提供了prompt模板PromptTemplate类由langchain_classic.prompts提供旧版本中为langchain.prompts。PromptTemplate类使用str.format()语法{}会自动解释为占位符如果需要{}作为字符串存在需要用{{和}}转义。这在输出为json、需要规定具体结构时可能有用。另外通过修改此处的system prompt特别是角色可以让模型作为其它领域的chatbot工作比如pdf是简历、说明书等。也可以适当调整内容将角色等内容设计为占位符由用户自行设置。最后这个模板并没有针对prompt注入进行有效防护措施。因为本文开发的只是一个chatbot在调用API模型时prompt注入攻击所能产生的恶性后果可预见地非常有限。对于Agent必须配置良好的防护措施取决于你Agent的功能边界。3.3.2 类初始化def __init__(self, config: Optional[PaperReaderConfig] None): self.config config or PaperReaderConfig() self.vector_store: Optional[FAISS] None self.qa_chain: Optional[RetrievalQA] None self.embeddings None self.llm None self.abstract_text: str self._init_components()没有特别需要强调的细节。3.3.3 加载嵌入模型def _init_embedding_api(self): api_key self.config.embedding_api_key or os.environ.get(SILICONFLOW_API_KEY) if not api_key: raise ValueError( 未配置嵌入模型API Key请设置 config.embedding_api_key 或环境变量 SILICONFLOW_API_KEY ) logger.info(使用API嵌入模型: %s, self.config.embedding_model_name) self.embeddings OpenAIEmbeddings( modelself.config.embedding_model_name, openai_api_baseself.config.embedding_api_url, openai_api_keyapi_key, tiktoken_enabledFalse, check_embedding_ctx_lengthFalse, )self.config.embedding_api_key是预留给前端输入的。具体而言def _create_agent( embedding_key: str, llm_key: str, temperature: float, embedding_mode: str api, ) - PaperReaderAgent: 根据 UI 参数创建 Agent 实例 config PaperReaderConfig( embedding_modeembedding_mode, embedding_api_keyembedding_key or None, llm_api_keyllm_key or None, temperaturetemperature, ) return PaperReaderAgent(config)ui.label(Embedding 模型BAAI/bge-large-zh-v1.5 · APISiliconFlow).classes(text-xs text-gray-500) embedding_key_input ( ui.input( labelSiliconFlow API Key, passwordTrue, password_toggle_buttonTrue, valueos.environ.get(SILICONFLOW_API_KEY, ), ) .classes(w-full) .props(clearable) )_init_embedding_local未实际使用跳过。_init_components将_init_embedding_api和LLM一并初始化逻辑与_init_embedding_api一致跳过。3.3.4 加载PDFdef load_pdf(self, pdf_path: str) - None: if not os.path.exists(pdf_path): raise FileNotFoundError(fPDF文件不存在: {pdf_path}) logger.info(读取PDF: %s, pdf_path) loader PyMuPDFLoader(pdf_path) documents loader.load() documents [doc for doc in documents if doc.page_content.strip()] total_chars sum(len(doc.page_content) for doc in documents) logger.info(读取完成共%d页总字符数: %d, len(documents), total_chars) if self.config.include_abstract and documents: self.abstract_text \n\n.join( doc.page_content for doc in documents[: self.config.abstract_pages] ) logger.info(提取前%d页作为摘要 (%d字符), self.config.abstract_pages, len(self.abstract_text)) text_splitter RecursiveCharacterTextSplitter( chunk_sizeself.config.chunk_size, chunk_overlapself.config.chunk_overlap, separators[\n\n, \n, . , , ], length_functionlen, ) chunks text_splitter.split_documents(documents) logger.info(分块完成共%d个文本块, len(chunks)) logger.info(构建向量索引...) self.vector_store FAISS.from_documents(chunks, self.embeddings) logger.info(向量索引构建完成) self._build_qa_chain() self.save_vector_store()使用配置中的abstract_pages对论文摘要进行提取得到的字符串为self.abstract_text以供后续步骤使用该属性作为注入内容。text_splitter使用的是langchain_text_splitters提供的RecursiveCharacterTextSplitter这是最常用的切分器之一。其主要特点是按换行、段落等结束符切分优先保持语义完整。可通过separators参数自定义切分标识如代码切分可“\nclass”。对于论文而言用换行切分基本足够使用。切分完成后通过FAISS建库第一个参数是chunks第二个参数是self.embeddings OpenAIEmbeddings(...)是用于将chunk转换为嵌入的模型。建库完成获得向量库self.vector_store进入下一步流程。3.3.5 QA Chaindef _build_qa_chain(self): if not self.vector_store: raise ValueError(请先加载PDF文档) base_retriever self.vector_store.as_retriever( search_typemmr, search_kwargs{ k: self.config.search_k, fetch_k: self.config.search_fetch_k, lambda_mult: self.config.mmr_lambda, }, ) retriever AbstractRetriever( base_retrieverbase_retriever, abstract_textself.abstract_text, include_abstractself.config.include_abstract, ) self.qa_chain RetrievalQA.from_chain_type( llmself.llm, chain_typestuff, retrieverretriever, chain_type_kwargs{prompt: self.QA_PROMPT}, )as_retriever方法将self.vector_store包装为标准的Retriever对象可以将检索器base_retriever理解为向量库self.vector_store的访问接口。通过3.2中定义的AbstractRetriever为检索器提供摘要注入的能力。最后构筑QA ChainRetrievalQA是langchain中用于RAG的标准chain作用是组装LLM、检索器和prompt。大致过程包括接收用户输入-调用检索器-构建prompt-调用LLM-返回LLM输出。3.3.6 面向用户的接口askdef ask(self, question: str) - str: if not self.qa_chain: raise ValueError(请先加载PDF文档) logger.info(处理问题: %s, question) response self.qa_chain.invoke({query: question}) return response[result]已经完成了QA Chain的构造还需要一个转换接口完成对QA Chain的调用。这里ask方法进行了PDF文档检查和question格式转换的功能。可以看出self.qa_chain通过invoke方法运行它本身的runnable规范更接近一个可调用的方法早期的类也确实可以通过__call__调用但由于不易维护、不易扩展和可读性差等诸多不利因素被舍弃。详情参考https://ai123.blog.csdn.net/article/details/147641943。3.4 入口函数def main(): 示例使用 config PaperReaderConfig() agent PaperReaderAgent(config) agent.load_pdf(./test_paper.pdf) answer agent.ask(文章的主要贡献是什么) print(f回答: {answer})4 WebUI前端是我用niceguicodebuddy改出来的但因为我不是很懂webui原本是做pyqt的但怕langchain和Windows兼容不好就放在WSL里做了所以webui最方便另外兼容性不好纯属我臆想的实际上并没有此事故只是放出这部分代码不详细介绍细节了 基于 NiceGUI 的论文阅读助手 WebUI import json import os import tempfile from typing import Optional from nicegui import ui from paper_reader import PaperReaderAgent, PaperReaderConfig # ── 全局状态 ────────────────────────────────────────────────────── agent: Optional[PaperReaderAgent] None loaded_pdf_name: str # ── 辅助函数 ────────────────────────────────────────────────────── def _create_agent( embedding_key: str, llm_key: str, temperature: float, embedding_mode: str api, ) - PaperReaderAgent: 根据 UI 参数创建 Agent 实例 config PaperReaderConfig( embedding_modeembedding_mode, embedding_api_keyembedding_key or None, llm_api_keyllm_key or None, temperaturetemperature, ) return PaperReaderAgent(config) # ── 主页面 ──────────────────────────────────────────────────────── ui.page(/) def main_page(): global agent, loaded_pdf_name # ── 自定义样式 ── ui.add_head_html( style body { background: #f0f4f8; } .header-card { background: linear-gradient(135deg, #0D47A1 0%, #1976D2 100%); color: #ffffff; border-radius: 12px; padding: 24px 28px; margin-bottom: 20px; box-shadow: 0 4px 20px rgba(25,118,210,0.25); } .status-loaded { color: #2E7D32; font-weight: 600; } .status-empty { color: #9E9E9E; } .result-area { background: #fafbfc; border: 1px solid #e0e0e0; border-radius: 8px; padding: 16px; min-height: 200px; max-height: 600px; overflow-y: auto; white-space: pre-wrap; font-size: 14px; line-height: 1.7; } .result-area blockquote { border-left: 3px solid #1976D2; padding-left: 12px; margin: 8px 0; color: #555; } .section-label { font-size: 13px; color: #78909C; text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 4px; } /* 上传组件将完成状态 ✓ 改为 X仅作用于文件头部图标 */ .q-uploader__file--status-uploaded .q-uploader__file-header .q-icon { visibility: hidden; position: relative; } .q-uploader__file--status-uploaded .q-uploader__file-header .q-icon::after { visibility: visible; position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%); font-family: Material Icons, Material Icons Outlined, sans-serif; font-size: 24px; content: close; } /* 保留文件类型图标第一个 icon不变 */ .q-uploader__file--status-uploaded .q-uploader__file-header .q-icon:first-child::after { content: none; } .q-uploader__file--status-uploaded .q-uploader__file-header .q-icon:first-child { visibility: visible; } /style ) # ── 顶部标题栏 ── with ui.element(div).classes(header-card): with ui.row().classes(items-center gap-4): ui.icon(menu_book, size36px).classes(opacity-90) with ui.column().classes(gap-0): ui.label(论文阅读助手).classes(text-2xl font-bold tracking-wide) ui.label(LangChain BGE Embedding DeepSeek).classes(text-sm opacity-75) # ══════════════════════════════════════════════════════════════ # 第一行PDF 上传 API 配置 # ══════════════════════════════════════════════════════════════ with ui.row().classes(w-full gap-4): # ── Card 1PDF 文档 ── with ui.card().classes(flex-1 min-w-[320px]): ui.label( 选择论文 PDF).classes(text-lg font-semibold text-blue-900) ui.separator() upload ui.upload( label拖拽或点击上传 PDF, on_uploadlambda e: handle_upload(e), auto_uploadTrue, ).classes(w-full).props(max-file-size52428800 max-files1) # 50MB, 仅1个文件 pdf_status ui.label(未加载文档).classes(status-empty text-sm) load_index_checkbox ui.checkbox(加载已有索引 (跳过PDF解析), valueFalse).classes(mt-2) load_index_btn ui.button( 加载本地索引, on_clicklambda: handle_load_index(), iconfolder_open, ).classes(mt-1).props(outline).bind_visibility_from(load_index_checkbox, value) # ── Card 2API 配置 ── with ui.card().classes(flex-1 min-w-[320px]): ui.label( API 配置).classes(text-lg font-semibold text-blue-900) ui.separator() ui.label(Embedding 模型BAAI/bge-large-zh-v1.5 · APISiliconFlow).classes(text-xs text-gray-500) embedding_key_input ( ui.input( labelSiliconFlow API Key, passwordTrue, password_toggle_buttonTrue, valueos.environ.get(SILICONFLOW_API_KEY, ), ) .classes(w-full) .props(clearable) ) ui.label(LLM 模型DeepSeek-Chat · APIapi.deepseek.com).classes(text-xs text-gray-500 mt-3) llm_key_input ( ui.input( labelDeepSeek API Key, passwordTrue, password_toggle_buttonTrue, valueos.environ.get(DEEPSEEK_API_KEY, ), ) .classes(w-full) .props(clearable) ) with ui.row().classes(items-center gap-2 mt-2 w-full): ui.label(温度).classes(text-sm text-gray-600 shrink-0) temp_slider ( ui.slider(min0.0, max1.0, step0.05, value0.1) .classes(flex-1) .props(label-always) ) temp_label ui.label(0.10).classes(text-sm font-mono w-10 text-right shrink-0) temp_slider.on( update:model-value, lambda e: temp_label.set_text(f{e.args:.2f}), ) # ══════════════════════════════════════════════════════════════ # 第二行提问 # ══════════════════════════════════════════════════════════════ with ui.card().classes(w-full mt-4): ui.label( 提问).classes(text-lg font-semibold text-blue-900) ui.separator() question_input ( ui.textarea( label输入你的问题, placeholder例如这篇文章的主要贡献是什么, ) .classes(w-full mt-3) .props(rows3 outlined) ) with ui.row().classes(mt-3 gap-3): submit_btn ui.button( 提交, on_clicklambda: handle_submit(), iconsend, ).classes(bg-blue-700 text-white) clear_btn ui.button( 清空输出, on_clicklambda: _set_result(), iconclear_all, ).props(flat) spinner ui.spinner(sizesm).classes(mt-1) spinner.set_visibility(False) # ══════════════════════════════════════════════════════════════ # 第三行结果展示 # ══════════════════════════════════════════════════════════════ with ui.card().classes(w-full mt-4): with ui.row().classes(w-full items-center justify-between): ui.label( 输出结果).classes(text-lg font-semibold text-blue-900) with ui.row().classes(gap-2): copy_btn ui.button( 复制, iconcontent_copy, on_clicklambda: ui.run_javascript( fnavigator.clipboard.writeText({json.dumps(result_text[value])}) ), ).props(flat dense) ui.separator() # 存储结果文本供复制按钮使用 result_text {value: } result_area ui.html().classes(result-area) def _set_result(content: str) - None: 更新结果区域并同步复制文本 result_text[value] content result_area.content content # ══════════════════════════════════════════════════════════════ # 事件处理 # ══════════════════════════════════════════════════════════════ async def handle_upload(e): 处理 PDF 文件上传NiceGUI 3.x: 文件数据在 e.file 中 global agent, loaded_pdf_name file_upload e.file # NiceGUI 3.x FileUpload 对象 filename file_upload.name embedding_key embedding_key_input.value.strip() llm_key llm_key_input.value.strip() if not embedding_key: ui.notify(请先填写 SiliconFlow API Key, typewarning, positiontop) return if not llm_key: ui.notify(请先填写 DeepSeek API Key, typewarning, positiontop) return try: spinner.set_visibility(True) pdf_status.set_text(⏳ 正在解析 PDF构建向量索引...) ui.notify(f开始处理: {filename}, typeinfo, positiontop) # 异步保存到临时文件 fd, tmp_path tempfile.mkstemp(suffix.pdf) os.close(fd) await file_upload.save(tmp_path) agent _create_agent( embedding_keyembedding_key, llm_keyllm_key, temperaturetemp_slider.value, ) agent.load_pdf(tmp_path) loaded_pdf_name filename pdf_status.set_text(f✅ 已加载: {loaded_pdf_name}).classes( status-loaded text-sm ) ui.notify(PDF 加载完成可以开始提问了, typepositive, positiontop) try: os.unlink(tmp_path) except OSError: pass # 上传完成后清空队列允许下次直接替换 upload.reset() except Exception as ex: pdf_status.set_text(f❌ 加载失败: {ex}).classes(text-red-500 text-sm) ui.notify(f加载失败: {ex}, typenegative, positiontop) finally: spinner.set_visibility(False) def handle_load_index(): 从本地加载已有向量索引 global agent, loaded_pdf_name embedding_key embedding_key_input.value.strip() llm_key llm_key_input.value.strip() if not embedding_key or not llm_key: ui.notify(请先填写 API Key, typewarning, positiontop) return try: spinner.set_visibility(True) pdf_status.set_text(⏳ 正在加载本地索引...) agent _create_agent( embedding_keyembedding_key, llm_keyllm_key, temperaturetemp_slider.value, ) agent.load_vector_store() loaded_pdf_name (从本地索引加载) pdf_status.set_text(f✅ 已加载本地向量索引).classes( status-loaded text-sm ) ui.notify(向量索引加载完成, typepositive, positiontop) except Exception as ex: pdf_status.set_text(f❌ 加载失败: {ex}).classes(text-red-500 text-sm) ui.notify(f加载失败: {ex}, typenegative, positiontop) finally: spinner.set_visibility(False) def handle_submit(): 提问题 global agent if not agent: ui.notify(请先加载 PDF 文档, typewarning, positiontop) return question question_input.value.strip() if not question: ui.notify(请输入问题, typewarning, positiontop) return temperature temp_slider.value # 如果温度变了更新 LLM 温度 if agent.llm and agent.llm.temperature ! temperature: agent.llm.temperature temperature try: spinner.set_visibility(True) submit_btn.disable() answer agent.ask(question) _set_result(answer) ui.notify(完成!, typepositive, positiontop) except Exception as ex: _set_result( fdiv stylecolor:#c62828;padding:12px; fstrong错误:/strong {ex}/div ) ui.notify(f处理失败: {ex}, typenegative, positiontop) finally: spinner.set_visibility(False) submit_btn.enable() # ── 启动入口 ─────────────────────────────────────────────────────── if __name__ in {__main__, __mp_main__}: ui.run( title论文阅读助手, favicon, port8080, reloadFalse, darkFalse, )UI效果5 展望其实这个项目很简单就是过了一下langchain的基本语法和方法。在这个项目基础上可改进如下前端支持嵌入模型和LLM选择、支持参数配置、支持自定义system promptprompt防注入优化不用QA而是用React做成真正的agent这里最直接的三个tool是arxiv直接读取论文、论文内github链接检索解析甚至部署、本文注入摘要工具化由模型选择是否需要摘要参考资料LangChainhttps://www.langchain.com/【LangChain】BaseLLM.__call__ 方法即直接调用 LLM 对象迁移至 invoke 方法