myagent-ai 1.9.9 → 1.10.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/agents/base.py CHANGED
@@ -142,6 +142,12 @@ class BaseAgent(ABC):
142
142
  if tools:
143
143
  request_kwargs["tools"] = tools
144
144
  request_kwargs["tool_choice"] = "auto"
145
+ # ── 推理模式 (Reasoning) ──
146
+ if self.llm.reasoning:
147
+ if self.llm.provider in self.llm._OPENAI_COMPATIBLE_PROVIDERS or self.llm.provider == "zhipu":
148
+ request_kwargs["reasoning_effort"] = self.llm.reasoning_effort
149
+ if self.llm.max_tokens < 8192:
150
+ request_kwargs["max_tokens"] = 8192
145
151
  request_kwargs.update(kwargs)
146
152
 
147
153
  full_text = ""
@@ -351,9 +357,13 @@ class BaseAgent(ABC):
351
357
  "arguments": args,
352
358
  })
353
359
 
360
+ # 对于推理模型(如 o1/DeepSeek-R1),如果 content 为空但有 reasoning 内容,
361
+ # 使用 reasoning 内容作为最终回复
362
+ final_content = full_text if full_text.strip() else (full_reasoning if full_reasoning.strip() else full_text)
363
+
354
364
  return LLMResponse(
355
365
  success=True,
356
- content=full_text,
366
+ content=final_content,
357
367
  tool_calls=final_tool_calls,
358
368
  finish_reason=finish_reason,
359
369
  model=request_kwargs.get("model", self.llm.model),
@@ -39,8 +39,9 @@ class MainAgent(BaseAgent):
39
39
  # =========================================================================
40
40
  SYSTEM_PROMPT = """你是一个强内容分析格式转化引擎,要深入分析以下上下文内容:
41
41
 
42
- 严格格式化输出以下内容:
42
+ 严格以XML格式化输出以下内容:
43
43
  <output>
44
+ <response>直接回复用户的内容。这是一段友好、自然的话语,用于向用户说明你正在做什么,或者回应用户的问题/问候。要求简洁、有礼貌、符合对话场景。如果用户只是问候,简单回应即可;如果用户有具体任务,要说明你的计划。</response>
44
45
  <usersays_correct>根据用户输入的"usersays"内容,结合上下文优化为新的用户输入,如果"usersays"为空,这里输出为空。</usersays_correct>
45
46
  <task_plan>如"context"包含非空"task_plan",则更新它,变为当前输出。否则,根据"context", 以MD 的格式,制定新任务列表。</task_plan>
46
47
 
@@ -59,18 +60,19 @@ class MainAgent(BaseAgent):
59
60
 
60
61
  ## 核心规则
61
62
  1. 你必须且只能输出 <output> XML 结构,不要输出任何其他文本
62
- 2. <usersays_correct>: 如果 context 中 usersays 非空,则根据对话语境优化为更准确的用户意图表达
63
- 3. <task_plan>: 使用 Markdown 列表格式,每项包含任务描述和完成状态标记 [x]/[ ]
64
- 4. <toolstocal>: 列出所有需要执行的工具调用,每个工具包含完整的参数说明
65
- 5. <timeout>: 预估超时秒数(简单操作10-30s,文件操作30-60s,网络请求60-120s,数据处理120-300s)
66
- 6. <callback>: 如果该工具的执行结果对后续决策有影响,设为 true;否则设为 false
67
- 7. <remember>: 仅从最新用户输入(userprint usersays_correct)中提炼值得长期记忆的关键信息,不要重复提炼历史对话中已有的记忆。如果本轮没有新信息需要记忆,则为空
68
- 8. <recall>: 描述下一轮执行时需要从记忆库中检索的内容关键词
69
- 9. <get_knowledge>: 如果当前 <knowledge> 内容不足以完成任务,填写需要从知识库搜索的关键词;否则为空
70
- 10. <askuser>: 当信息不足需要用户补充时,在此填写要问的问题
71
- 11. <finish>: 当任务已完成或需要等待用户回应时为 true;否则为 false 继续执行
72
- 12. 使用中文输出所有内容
73
- 13. 优先使用技能系统(skill)而非直接写代码执行
63
+ 2. <response>: 必须输出一段直接回复用户的话语(这是用户实际看到的回复),要求简洁友好、自然流畅。不要只输出任务计划而不说话!
64
+ 3. <usersays_correct>: 如果 context usersays 非空,则根据对话语境优化为更准确的用户意图表达
65
+ 4. <task_plan>: 使用 Markdown 列表格式,每项包含任务描述和完成状态标记 [x]/[ ]
66
+ 5. <toolstocal>: 列出所有需要执行的工具调用,每个工具包含完整的参数说明
67
+ 6. <timeout>: 预估超时秒数(简单操作10-30s,文件操作30-60s,网络请求60-120s,数据处理120-300s)
68
+ 7. <callback>: 如果该工具的执行结果对后续决策有影响,设为 true;否则设为 false
69
+ 8. <remember>: 仅从最新用户输入(userprint 或 usersays_correct)中提炼值得长期记忆的关键信息,不要重复提炼历史对话中已有的记忆。如果本轮没有新信息需要记忆,则为空
70
+ 9. <recall>: 描述下一轮执行时需要从记忆库中检索的内容关键词
71
+ 10. <get_knowledge>: 如果当前 <knowledge> 内容不足以完成任务,填写需要从知识库搜索的关键词;否则为空
72
+ 11. <askuser>: 当信息不足需要用户补充时,在此填写要问的问题
73
+ 12. <finish>: 当任务已完成或需要等待用户回应时为 true;否则为 false 继续执行
74
+ 13. 使用中文输出所有内容
75
+ 14. 优先使用技能系统(skill)而非直接写代码执行
74
76
  """
75
77
 
76
78
  def __init__(self, tool_agent=None, memory_agent=None, **kwargs):
@@ -173,8 +175,6 @@ class MainAgent(BaseAgent):
173
175
  stream_callback(event)
174
176
  except Exception as e:
175
177
  logger.debug(f"V2 SSE 事件发送失败 ({event_type}): {e}")
176
- else:
177
- logger.debug(f"[v2-event] {event_type}: {data}")
178
178
 
179
179
  async def _merge_duplicate_memory(
180
180
  self,
@@ -323,12 +323,29 @@ class MainAgent(BaseAgent):
323
323
 
324
324
  conversation_history = list(context.conversation_history or [])
325
325
 
326
+ # 从 DB 加载历史对话(如果 conversation_history 为空且 memory 可用)
327
+ if self.memory and not conversation_history:
328
+ try:
329
+ db_history = self.memory.get_conversation(
330
+ session_id=context.session_id,
331
+ limit=100,
332
+ )
333
+ if db_history:
334
+ conversation_history = [
335
+ Message(role=entry.role, content=entry.content)
336
+ for entry in db_history
337
+ ]
338
+ logger.info(f"[{task_id}] 从 DB 加载了 {len(conversation_history)} 条历史对话")
339
+ except Exception as e:
340
+ logger.warning(f"[{task_id}] 加载历史对话失败: {e}")
341
+
326
342
  # 保存用户消息到短期记忆
327
343
  if self.memory:
328
344
  self.memory.add_short_term(
329
345
  session_id=context.session_id,
330
346
  role="user",
331
347
  content=context.user_message,
348
+ key="user_input",
332
349
  )
333
350
 
334
351
  # 加载相关记忆 (recall from previous round or initial load)
@@ -391,7 +408,6 @@ class MainAgent(BaseAgent):
391
408
  + self.SYSTEM_PROMPT.split("\n", 1)[1]
392
409
  )
393
410
  system_content = _prompt_with_placeholder.replace(_CONTEXT_PLACEHOLDER, context_xml)
394
-
395
411
  # Step 3: 调用 LLM
396
412
  messages = [Message(role="system", content=system_content)]
397
413
 
@@ -420,6 +436,16 @@ class MainAgent(BaseAgent):
420
436
  llm_raw = response.content
421
437
  logger.debug(f"[{task_id}] LLM 输出 (前500字): {llm_raw[:500]}")
422
438
 
439
+ # 保存 LLM 原始输出到短期记忆(用于回溯和审计)
440
+ if self.memory:
441
+ self.memory.add_short_term(
442
+ session_id=context.session_id,
443
+ role="assistant",
444
+ content=llm_raw,
445
+ key="llm_output",
446
+ importance=0.3,
447
+ )
448
+
423
449
  # Step 4: 解析结构化输出
424
450
  parsed = parse_output(llm_raw)
425
451
 
@@ -452,7 +478,17 @@ class MainAgent(BaseAgent):
452
478
  )
453
479
  break
454
480
  else:
455
- context.working_memory["final_response"] = llm_raw
481
+ # XML 解析失败且无法提取文本,发送原始输出作为备选
482
+ logger.warning(f"[{task_id}] 无法提取文本,发送原始 LLM 输出")
483
+ final_text = llm_raw.strip() if llm_raw.strip() else "处理完毕。"
484
+ context.working_memory["final_response"] = final_text
485
+ await self._emit_v2_event("v2_reasoning", {"content": final_text}, stream_callback)
486
+ if self.memory:
487
+ self.memory.add_short_term(
488
+ session_id=context.session_id,
489
+ role="assistant",
490
+ content=final_text,
491
+ )
456
492
  break
457
493
 
458
494
  warnings = validate_output(parsed)
@@ -463,6 +499,18 @@ class MainAgent(BaseAgent):
463
499
  if parsed.usersays_correct:
464
500
  context.working_memory["usersays_correct"] = parsed.usersays_correct
465
501
 
502
+ # Step 5.5: 处理 response — 直接回复用户的内容
503
+ if parsed.response:
504
+ response_text = parsed.response.strip()
505
+ if response_text:
506
+ logger.debug(f"[{task_id}] 模型回复用户: {response_text[:100]}")
507
+ context.working_memory["model_response"] = response_text
508
+ await self._emit_v2_event(
509
+ "v2_reasoning",
510
+ {"content": response_text},
511
+ stream_callback,
512
+ )
513
+
466
514
  # Step 6: 处理 remember — 查重+LLM合并后存入长期记忆
467
515
  if parsed.remember:
468
516
  try:
@@ -645,11 +693,30 @@ class MainAgent(BaseAgent):
645
693
  )
646
694
 
647
695
  # 发送工具结果事件
696
+ # 提取实际输出:SkillResult 有 output/message/data,ExecResult 有 stdout/stderr
697
+ def _extract_tool_output(tr):
698
+ """从工具结果中提取实际输出文本"""
699
+ out = tr.get("output", "")
700
+ if out:
701
+ return out
702
+ out = tr.get("message", "")
703
+ if out:
704
+ return out
705
+ out = tr.get("stdout", "")
706
+ if out:
707
+ return out
708
+ data = tr.get("data")
709
+ if data is not None:
710
+ return str(data) if not isinstance(data, str) else data
711
+ return tr.get("error", "")
712
+
713
+ tool_output_text = _extract_tool_output(tool_result)
714
+
648
715
  await self._emit_v2_event(
649
716
  "v2_tool_result",
650
717
  {"tool": {"toolname": tool_name}, "result": {
651
718
  "success": tool_result.get("success", False),
652
- "output": truncate_str(tool_result.get("output", ""), 3000),
719
+ "output": truncate_str(tool_output_text, 3000),
653
720
  "error": truncate_str(tool_result.get("error", ""), 1000),
654
721
  "timed_out": tool_result.get("timed_out", False),
655
722
  }},
@@ -660,7 +727,7 @@ class MainAgent(BaseAgent):
660
727
  "title": f"工具结果: {tool_name}",
661
728
  "tool_name": tool_name,
662
729
  "success": tool_result.get("success", False),
663
- "summary": truncate_str(str(tool_result.get("output", tool_result.get("error", ""))), 500),
730
+ "summary": truncate_str(tool_output_text, 500),
664
731
  "result": tool_result,
665
732
  })
666
733
 
@@ -671,7 +738,7 @@ class MainAgent(BaseAgent):
671
738
  elif should_callback:
672
739
  need_callback = True
673
740
 
674
- output_str = tool_result.get("output", "") or tool_result.get("error", "")
741
+ output_str = tool_output_text
675
742
  tool_outputs_parts.append(
676
743
  f"### {before_call}\n"
677
744
  f"**工具**: {tool_name}\n"
@@ -688,6 +755,23 @@ class MainAgent(BaseAgent):
688
755
  content=f"[工具 {tool_name} 执行完成] {'成功' if tool_result.get('success') else '失败'}",
689
756
  ))
690
757
 
758
+ # 保存工具调用到短期记忆
759
+ if self.memory:
760
+ self.memory.add_short_term(
761
+ session_id=context.session_id,
762
+ role="assistant",
763
+ content=f"调用工具: {tool_name}\n参数: {truncate_str(parms, 1000)}",
764
+ key="tool_call",
765
+ importance=0.4,
766
+ )
767
+ self.memory.add_short_term(
768
+ session_id=context.session_id,
769
+ role="tool",
770
+ content=f"[{tool_name}] {'成功' if tool_result.get('success') else '失败'}\n{truncate_str(output_str, 5000)}",
771
+ key="tool_result",
772
+ importance=0.4,
773
+ )
774
+
691
775
  all_tool_outputs = "\n".join(tool_outputs_parts)
692
776
 
693
777
  # Step 12: 工具执行完毕后,根据 finish 标志决定是否回调 LLM
package/config.py CHANGED
@@ -23,11 +23,15 @@ from typing import Optional, Dict, Any, List
23
23
  class LLMConfig:
24
24
  """LLM 大模型配置"""
25
25
  provider: str = "openai" # openai | anthropic | ollama | custom
26
+ api_type: str = "openai-completions" # API 类型
26
27
  api_key: str = ""
27
28
  base_url: str = "https://api.openai.com/v1"
28
29
  model: str = "gpt-4"
29
30
  temperature: float = 0.1
30
31
  max_tokens: int = 4096
32
+ context_window: int = 128000 # 上下文窗口大小
33
+ input_modes: List[str] = field(default_factory=lambda: ["text"]) # 支持的输入模式
34
+ reasoning: bool = False # 是否支持推理
31
35
  timeout: int = 120 # 请求超时(秒)
32
36
  max_retries: int = 3 # 最大重试次数
33
37
  # Anthropic 专用
@@ -86,11 +90,15 @@ class ModelEntry:
86
90
  id: str = "" # 唯一标识符,如 "gpt-4o", "claude-3.5-sonnet"
87
91
  name: str = "" # 显示名称
88
92
  provider: str = "" # openai | anthropic | ollama | zhipu | custom
93
+ api_type: str = "openai-completions" # API 类型:openai-completions | openai-chat | anthropic | ollama | custom
89
94
  model: str = "" # API 调用使用的实际模型字符串
90
95
  base_url: str = "" # 自定义 Base URL(空=使用 provider 默认值)
91
96
  api_key: str = "" # 专用 API Key(空=使用全局默认值)
92
97
  max_tokens: int = 4096
93
98
  temperature: float = 0.1
99
+ context_window: int = 128000 # 上下文窗口大小(token)
100
+ input_modes: List[str] = field(default_factory=lambda: ["text"]) # 支持的输入模式: text, image, video, audio
101
+ reasoning: bool = True # 是否支持推理(如 o1 系列)
94
102
  enabled: bool = True
95
103
 
96
104
 
@@ -118,10 +118,11 @@ class ContextBuilder:
118
118
  kb_query = get_knowledge.strip() if get_knowledge else query
119
119
 
120
120
  sections: List[str] = [
121
+ self._build_datetime(),
121
122
  self._build_whomi(agent_name, agent_description, agent_override_prompt),
122
123
  self._build_memory(query, session_id),
123
124
  self._build_knowledge(kb_query),
124
- self._build_recent_dialog(conversation_history, self.max_dialog_chars),
125
+ self._build_recent_dialog(conversation_history, self.max_dialog_chars, session_id),
125
126
  self._build_user_input(user_typed_text, user_voice_text),
126
127
  self._build_task_plan(task_plan),
127
128
  self._build_tools(self.skill_registry),
@@ -140,6 +141,24 @@ class ContextBuilder:
140
141
  # 各段落构建方法
141
142
  # =========================================================================
142
143
 
144
+ def _build_datetime(self) -> str:
145
+ """
146
+ 构建 <datetime> 段落 —— 当前日期时间(精确到秒)。
147
+ 让 LLM 知道当前时间,以便给出与时间相关的回答。
148
+ """
149
+ from datetime import datetime
150
+ now = datetime.now()
151
+ weekdays = ["星期一", "星期二", "星期三", "星期四", "星期五", "星期六", "星期日"]
152
+ date_str = now.strftime("%Y年%m月%d日")
153
+ time_str = now.strftime("%H:%M:%S")
154
+ weekday = weekdays[now.weekday()]
155
+ return (
156
+ f"<datetime>\n"
157
+ f"当前时间: {date_str} {weekday} {time_str}\n"
158
+ f"时间戳: {now.timestamp()}\n"
159
+ f"</datetime>"
160
+ )
161
+
143
162
  def _build_whomi(
144
163
  self,
145
164
  agent_name: str,
@@ -275,16 +294,18 @@ class ContextBuilder:
275
294
  self,
276
295
  conversation_history: List["Message"],
277
296
  max_chars: int,
297
+ session_id: str = "",
278
298
  ) -> str:
279
299
  """
280
300
  构建 <resentdialog> 段落 —— 近期对话历史。
281
301
 
282
- 将对话格式化为带角色标签的文本,并截断到指定最大字符数。
283
- 保留 agent (assistant) 和 user 的消息,使用中文标签。
302
+ 将对话格式化为带角色标签的文本。当历史过长时,将较早的消息
303
+ 压缩为摘要,保留近期消息完整呈现,总字符数不超过 max_chars。
284
304
 
285
305
  Args:
286
306
  conversation_history: 对话历史消息列表
287
307
  max_chars: 最大字符数限制
308
+ session_id: 会话 ID(用于 MemoryManager 摘要)
288
309
 
289
310
  Returns:
290
311
  <resentdialog> XML 段落字符串
@@ -300,32 +321,126 @@ class ContextBuilder:
300
321
  "tool": "工具",
301
322
  }
302
323
 
303
- # 格式化每条消息
304
- formatted_lines: List[str] = []
324
+ # 过滤空消息并格式化
325
+ filtered_msgs: List[tuple] = []
305
326
  for msg in conversation_history:
306
327
  role = getattr(msg, "role", "user")
307
328
  content = getattr(msg, "content", "")
308
329
  if not content.strip():
309
330
  continue
310
-
311
331
  label = role_labels.get(role, role)
312
- formatted_lines.append(f"[{label}] {_xml_escape(content.strip())}")
332
+ filtered_msgs.append((label, content.strip()))
333
+
334
+ if not filtered_msgs:
335
+ return "<resentdialog>\n(无对话历史)\n</resentdialog>"
336
+
337
+ # 当消息超过阈值时,将旧消息压缩为摘要
338
+ SUMMARY_THRESHOLD = 30 # 超过30条时启用摘要
339
+ RECENT_KEEP = 15 # 保留最近15条完整消息
340
+ SUMMARY_BUDGET = 3000 # 摘要最大字符数
341
+
342
+ prefix_text = ""
343
+ recent_msgs = filtered_msgs
344
+
345
+ if len(filtered_msgs) > SUMMARY_THRESHOLD:
346
+ old_msgs = filtered_msgs[:-RECENT_KEEP]
347
+ recent_msgs = filtered_msgs[-RECENT_KEEP:]
348
+ prefix_text = self._build_dialog_summary(old_msgs, SUMMARY_BUDGET)
349
+
350
+ # 格式化近期消息
351
+ formatted_lines: List[str] = []
352
+ if prefix_text:
353
+ formatted_lines.append(prefix_text)
354
+ formatted_lines.append("") # 空行分隔
355
+
356
+ for label, content in recent_msgs:
357
+ formatted_lines.append(f"[{label}] {_xml_escape(content)}")
313
358
 
314
359
  dialog_text = "\n".join(formatted_lines)
315
360
 
316
- # 截断到最大字符数
361
+ # Token 预算裁剪:超预算时从最早的消息开始移除
317
362
  if len(dialog_text) > max_chars:
318
- # 从尾部截断,避免丢失最新的对话
319
- dialog_text = dialog_text[-max_chars:]
320
- # 从截断点找到最近的换行符以保持完整性
321
- newline_pos = dialog_text.find("\n")
322
- if newline_pos > 0 and newline_pos < 500:
323
- dialog_text = dialog_text[newline_pos + 1:]
324
- # 添加截断标记
325
- dialog_text = "(... 前面的对话已被截断 ...)\n" + dialog_text
363
+ # 保留摘要前缀,裁剪近期消息部分
364
+ if prefix_text:
365
+ # 先尝试只裁剪近期消息
366
+ recent_budget = max_chars - len(prefix_text) - 100
367
+ if recent_budget < 500:
368
+ # 摘要本身太长,整体裁剪
369
+ dialog_text = dialog_text[-max_chars:]
370
+ else:
371
+ recent_part = "\n".join(formatted_lines[len(formatted_lines) - len(recent_msgs):])
372
+ if len(recent_part) > recent_budget:
373
+ recent_part = self._trim_messages_from_start(recent_part, recent_budget)
374
+ dialog_text = prefix_text + "\n\n" + recent_part
375
+ else:
376
+ dialog_text = self._trim_messages_from_start(dialog_text, max_chars)
377
+ dialog_text = "(... 前面的对话已被裁剪 ...)\n" + dialog_text
326
378
 
327
379
  return f"<resentdialog>\n{dialog_text}\n</resentdialog>"
328
380
 
381
+ def _build_dialog_summary(self, old_msgs: List[tuple], max_chars: int) -> str:
382
+ """
383
+ 将旧消息列表压缩为摘要文本。
384
+
385
+ 策略: 提取每条消息的第一行作为要点,保留关键信息的同时大幅压缩篇幅。
386
+ 如果有 MemoryManager,也可以调用 LLM 生成摘要(未来扩展)。
387
+
388
+ Args:
389
+ old_msgs: (label, content) 元组列表
390
+ max_chars: 摘要最大字符数
391
+
392
+ Returns:
393
+ 摘要文本字符串
394
+ """
395
+ if not old_msgs:
396
+ return ""
397
+
398
+ summary_parts: List[str] = ["[历史对话摘要]"]
399
+ for label, content in old_msgs:
400
+ # 提取第一行或前100字符作为要点
401
+ first_line = content.split("\n")[0].strip()
402
+ if len(first_line) > 100:
403
+ first_line = first_line[:100] + "..."
404
+ summary_parts.append(f"- [{label}] {first_line}")
405
+
406
+ summary_text = "\n".join(summary_parts)
407
+
408
+ # 摘要本身也要限制长度
409
+ if len(summary_text) > max_chars:
410
+ summary_text = summary_text[:max_chars] + "\n... (更多历史已省略)"
411
+
412
+ return summary_text
413
+
414
+ def _trim_messages_from_start(self, text: str, max_chars: int) -> str:
415
+ """
416
+ 从文本开头裁剪消息,保留尾部(最新消息优先)。
417
+
418
+ 按行(即按消息)为单位裁剪,避免在消息中间截断。
419
+
420
+ Args:
421
+ text: 格式化后的对话文本
422
+ max_chars: 最大保留字符数
423
+
424
+ Returns:
425
+ 裁剪后的文本
426
+ """
427
+ if len(text) <= max_chars:
428
+ return text
429
+
430
+ lines = text.split("\n")
431
+ result_lines: List[str] = []
432
+ total = 0
433
+
434
+ # 从后往前添加行,保留最新的消息
435
+ for line in reversed(lines):
436
+ if total + len(line) + 1 > max_chars:
437
+ break
438
+ result_lines.append(line)
439
+ total += len(line) + 1
440
+
441
+ result_lines.reverse()
442
+ return "\n".join(result_lines)
443
+
329
444
  def _build_user_input(
330
445
  self,
331
446
  user_typed_text: str,
package/core/logger.py CHANGED
@@ -148,6 +148,12 @@ def setup_logger(
148
148
  # 控制台输出
149
149
  if console:
150
150
  ch = logging.StreamHandler(sys.stdout)
151
+ # Windows 兼容 UTF-8
152
+ if sys.platform == "win32":
153
+ try:
154
+ sys.stdout.reconfigure(encoding="utf-8")
155
+ except Exception:
156
+ pass
151
157
  ch.setFormatter(ColorFormatter(fmt, datefmt=datefmt))
152
158
  ch.setLevel(logging.DEBUG) # 控制台始终显示所有级别
153
159
  logger.addHandler(ch)
@@ -216,7 +222,16 @@ def get_logger(name: str = "myagent") -> logging.Logger:
216
222
  """获取已存在的 Logger,如果不存在则创建默认的"""
217
223
  logger = logging.getLogger(name)
218
224
  if not logger.handlers:
219
- return setup_logger(name)
225
+ # 子 logger 继承父 logger 的 handlers(如文件 handler)
226
+ parent_logger = logging.getLogger()
227
+ if parent_logger.handlers:
228
+ for handler in parent_logger.handlers:
229
+ logger.addHandler(handler)
230
+ logger.setLevel(parent_logger.level)
231
+ logger.propagate = parent_logger.propagate
232
+ else:
233
+ # 没有父 logger,创建默认配置
234
+ return setup_logger(name)
220
235
  return logger
221
236
 
222
237